Merge branch 'accounting' into develop
This commit is contained in:
commit
aac28ddee7
@ -29,10 +29,6 @@
|
||||
|
||||
*$$$*
|
||||
|
||||
buy supporter pack
|
||||
account credited with features
|
||||
char sets
|
||||
emotes
|
||||
|
||||
|
||||
* balances table (ingame currency)
|
||||
|
||||
@ -297,7 +297,7 @@ button[disabled] {
|
||||
}
|
||||
|
||||
/*
|
||||
HEADER
|
||||
account
|
||||
*/
|
||||
|
||||
header {
|
||||
@ -307,23 +307,26 @@ header {
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
.account {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.account-title {
|
||||
flex: 1;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.header-status {
|
||||
margin: 1em 0;
|
||||
.account-status {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.header-username {
|
||||
.account-header {
|
||||
letter-spacing: 0.05em;
|
||||
flex: 1;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.header-status svg {
|
||||
.account-status svg {
|
||||
margin: 0.5em 0 0 1em;
|
||||
height: 1em;
|
||||
background-color: black;
|
||||
@ -466,6 +469,20 @@ header {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.stripe-btn {
|
||||
width: 100%;
|
||||
padding: 0 0.5em;
|
||||
margin: 0.25em 0;
|
||||
background: whitesmoke;
|
||||
color: black;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.stripe-btn:hover {
|
||||
color: black;
|
||||
}
|
||||
|
||||
|
||||
.refresh-btn {
|
||||
border: 1px solid #222;
|
||||
float: right;
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
<script src="https://js.stripe.com/v3/"></script>
|
||||
<script src="./index.js"></script>
|
||||
<script>
|
||||
// Check that service workers are registered
|
||||
|
||||
@ -25,8 +25,10 @@
|
||||
"node-sass": "^4.12.0",
|
||||
"parcel": "^1.12.3",
|
||||
"preact": "^8.4.2",
|
||||
"preact-compat": "^3.19.0",
|
||||
"preact-context": "^1.1.3",
|
||||
"preact-redux": "^2.1.0",
|
||||
"react-stripe-elements": "^3.0.0",
|
||||
"redux": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -39,5 +41,9 @@
|
||||
"eslint-plugin-import": "^2.14.0",
|
||||
"eslint-plugin-react": "^7.11.1",
|
||||
"jest": "^18.0.0"
|
||||
},
|
||||
"alias": {
|
||||
"react": "preact-compat",
|
||||
"react-dom": "preact-compat"
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ const preact = require('preact');
|
||||
|
||||
const { Provider, connect } = require('preact-redux');
|
||||
const { createStore, combineReducers } = require('redux');
|
||||
const { StripeProvider } = require('react-stripe-elements');
|
||||
|
||||
const reducers = require('./reducers');
|
||||
const actions = require('./actions');
|
||||
@ -28,7 +29,9 @@ document.fonts.load('16pt "Jura"').then(() => {
|
||||
|
||||
const App = () => (
|
||||
<Provider store={store}>
|
||||
<StripeProvider apiKey="pk_test_PiLzjIQE7zUy3Xpott7tdQbl00uLiCesTa">
|
||||
<Mnml />
|
||||
</StripeProvider>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
|
||||
107
client/src/components/account.status.jsx
Normal file
107
client/src/components/account.status.jsx
Normal file
@ -0,0 +1,107 @@
|
||||
const { connect } = require('preact-redux');
|
||||
const preact = require('preact');
|
||||
const { Elements, injectStripe } = require('react-stripe-elements');
|
||||
|
||||
const { saw } = require('./shapes');
|
||||
|
||||
function pingColour(ping) {
|
||||
if (ping < 100) return 'forestgreen';
|
||||
if (ping < 200) return 'yellow';
|
||||
return 'red';
|
||||
}
|
||||
|
||||
function BitsBtn(args) {
|
||||
const {
|
||||
stripe,
|
||||
account,
|
||||
} = args;
|
||||
function subscribeClick(e) {
|
||||
stripe.redirectToCheckout({
|
||||
items: [{plan: 'plan_FGmRwawcOJJ7Nv', quantity: 1}],
|
||||
successUrl: 'http://localhost:40080/payments/success',
|
||||
cancelUrl: 'http://localhost:40080/payments/cancel',
|
||||
clientReferenceId: account.id
|
||||
});
|
||||
}
|
||||
|
||||
function bitsClick(e) {
|
||||
stripe.redirectToCheckout({
|
||||
items: [{sku: 'sku_FHUfNEhWQaVDaT', quantity: 1}],
|
||||
successUrl: 'http://localhost:40080/payments/success',
|
||||
cancelUrl: 'http://localhost:40080/payments/cancel',
|
||||
clientReferenceId: account.id
|
||||
});
|
||||
}
|
||||
|
||||
const subscription = account.subscribed
|
||||
? <h3 class="account-header">Subscribed</h3>
|
||||
: <button
|
||||
onClick={subscribeClick}
|
||||
class="stripe-btn"
|
||||
role="link">
|
||||
Subscribe
|
||||
</button>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div id="error-message"></div>
|
||||
{subscription}
|
||||
<button
|
||||
onClick={bitsClick}
|
||||
class="stripe-btn"
|
||||
role="link">
|
||||
Get Bits
|
||||
</button>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const StripeBitsBtn = injectStripe(BitsBtn);
|
||||
|
||||
const addState = connect(
|
||||
function receiveState(state) {
|
||||
const {
|
||||
account,
|
||||
ping,
|
||||
} = state;
|
||||
|
||||
function logout() {
|
||||
postData('/logout').then(() => window.location.reload(true));
|
||||
}
|
||||
|
||||
return {
|
||||
account,
|
||||
ping,
|
||||
logout,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
function AccountStatus(args) {
|
||||
const {
|
||||
account,
|
||||
ping,
|
||||
logout,
|
||||
} = args;
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
return (
|
||||
<div class="account">
|
||||
<div class="account-status">
|
||||
<h2 class="account-header">{account.name}</h2>
|
||||
{saw(pingColour(ping))}
|
||||
<div class="ping-text">{ping}ms</div>
|
||||
</div>
|
||||
<h3 class="account-header">{`¤${account.credits}`}</h3>
|
||||
<Elements>
|
||||
<StripeBitsBtn account={account} />
|
||||
</Elements>
|
||||
<button onClick={() => logout()}>Logout</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = addState(AccountStatus);
|
||||
@ -3,16 +3,29 @@ const preact = require('preact');
|
||||
const { Component } = require('preact')
|
||||
const { connect } = require('preact-redux');
|
||||
|
||||
const { postData } = require('../utils');
|
||||
|
||||
const addState = connect(
|
||||
(state) => {
|
||||
const { ws, account } = state;
|
||||
const {
|
||||
ws
|
||||
} = state;
|
||||
function submitLogin(name, password) {
|
||||
return ws.sendAccountLogin(name, password);
|
||||
postData('/login', { name, password })
|
||||
.then(data => ws.connect())
|
||||
.catch(error => console.error(error));
|
||||
}
|
||||
|
||||
function submitRegister(name, password, code) {
|
||||
return ws.sendAccountCreate(name, password, code);
|
||||
postData('/register', { name, password, code })
|
||||
.then(data => ws.connect())
|
||||
.catch(error => console.error(error));
|
||||
}
|
||||
|
||||
return {
|
||||
submitLogin,
|
||||
submitRegister,
|
||||
}
|
||||
return { account, submitLogin, submitRegister };
|
||||
},
|
||||
);
|
||||
|
||||
@ -65,6 +78,7 @@ class Login extends Component {
|
||||
class="login-input"
|
||||
type="email"
|
||||
placeholder="username"
|
||||
tabIndex={1}
|
||||
value={this.state.name}
|
||||
onInput={this.nameInput}
|
||||
/>
|
||||
@ -72,6 +86,7 @@ class Login extends Component {
|
||||
class="login-input"
|
||||
type="password"
|
||||
placeholder="password"
|
||||
tabIndex={2}
|
||||
value={this.state.password}
|
||||
onInput={this.passwordInput}
|
||||
/>
|
||||
@ -79,16 +94,19 @@ class Login extends Component {
|
||||
class="login-input"
|
||||
type="text"
|
||||
placeholder="code"
|
||||
tabIndex={3}
|
||||
value={this.state.code}
|
||||
onInput={this.codeInput}
|
||||
/>
|
||||
<button
|
||||
class="login-btn"
|
||||
tabIndex={4}
|
||||
onClick={this.loginSubmit}>
|
||||
Login
|
||||
</button>
|
||||
<button
|
||||
class="login-btn"
|
||||
tabIndex={5}
|
||||
onClick={this.registerSubmit}>
|
||||
Register
|
||||
</button>
|
||||
|
||||
@ -10,8 +10,8 @@ const List = require('./list');
|
||||
|
||||
const addState = connect(
|
||||
state => {
|
||||
const { game, instance, account, nav, team } = state;
|
||||
return { game, instance, account, nav, team };
|
||||
const { game, instance, account, nav, team, constructs } = state;
|
||||
return { game, instance, account, nav, team, constructs };
|
||||
}
|
||||
);
|
||||
|
||||
@ -22,6 +22,7 @@ function Main(props) {
|
||||
account,
|
||||
nav,
|
||||
team,
|
||||
constructs,
|
||||
} = props;
|
||||
|
||||
if (!account) {
|
||||
@ -36,8 +37,8 @@ function Main(props) {
|
||||
return <Instance />;
|
||||
}
|
||||
|
||||
if (nav === 'team' || !team.some(t => t) || constructs.length < 3) return <Team />;
|
||||
if (nav === 'list') return <List />;
|
||||
if (nav === 'team' || !team.some(t => t)) return <Team />;
|
||||
|
||||
return (
|
||||
<main></main>
|
||||
|
||||
@ -1,19 +1,14 @@
|
||||
const { connect } = require('preact-redux');
|
||||
const preact = require('preact');
|
||||
const { Fragment } = require('preact');
|
||||
const actions = require('../actions');
|
||||
|
||||
const { saw } = require('./shapes');
|
||||
const { postData } = require('../utils');
|
||||
const actions = require('../actions');
|
||||
const AccountStatus = require('./account.status');
|
||||
|
||||
const testGame = process.env.NODE_ENV === 'development' && require('./../test.game');
|
||||
const testInstance = process.env.NODE_ENV === 'development' && require('./../test.instance');
|
||||
|
||||
function pingColour(ping) {
|
||||
if (ping < 100) return 'forestgreen';
|
||||
if (ping < 200) return 'yellow';
|
||||
return 'red';
|
||||
}
|
||||
|
||||
const addState = connect(
|
||||
function receiveState(state) {
|
||||
const {
|
||||
@ -38,6 +33,10 @@ const addState = connect(
|
||||
return ws.sendInstanceList();
|
||||
}
|
||||
|
||||
function logout() {
|
||||
postData('/logout').then(() => window.location.reload(true));
|
||||
}
|
||||
|
||||
return {
|
||||
account,
|
||||
instances,
|
||||
@ -47,6 +46,7 @@ const addState = connect(
|
||||
sendInstanceState,
|
||||
sendAccountInstances,
|
||||
sendInstanceList,
|
||||
logout,
|
||||
};
|
||||
},
|
||||
function receiveDispatch(dispatch) {
|
||||
@ -89,10 +89,9 @@ const addState = connect(
|
||||
function Nav(args) {
|
||||
const {
|
||||
account,
|
||||
ping,
|
||||
team,
|
||||
instances,
|
||||
game,
|
||||
team,
|
||||
|
||||
sendInstanceState,
|
||||
sendAccountInstances,
|
||||
@ -138,18 +137,11 @@ function Nav(args) {
|
||||
|
||||
const canJoin = team.some(c => !c);
|
||||
|
||||
const accountStatus = account
|
||||
? (<div class="header-status">
|
||||
<h2 class="header-username">{account.name}</h2>
|
||||
{saw(pingColour(ping))}
|
||||
<div class="ping-text">{ping}ms</div>
|
||||
</div>)
|
||||
: false;
|
||||
|
||||
return (
|
||||
<nav onClick={hideNav} >
|
||||
<h1 class="header-title">mnml.gg</h1>
|
||||
{accountStatus}
|
||||
<AccountStatus />
|
||||
<hr />
|
||||
<button onClick={() => navTo('team')}>Select Team</button>
|
||||
<button disabled={canJoin} onClick={() => navTo('list')}>Play</button>
|
||||
<hr />
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
const toast = require('izitoast');
|
||||
const cbor = require('borc');
|
||||
|
||||
const SOCKET_URL = process.env.NODE_ENV === 'production' ? 'wss://mnml.gg/ws' : 'ws://localhost:40000';
|
||||
const SOCKET_URL = process.env.NODE_ENV === 'production' ? 'wss://mnml.gg/api/ws' : 'ws://localhost:40000/api/ws';
|
||||
|
||||
function errorToast(err) {
|
||||
console.error(err);
|
||||
@ -15,21 +15,20 @@ function errorToast(err) {
|
||||
function createSocket(events) {
|
||||
let ws;
|
||||
|
||||
// handle account auth within the socket itself
|
||||
// https://www.christian-schneider.net/CrossSiteWebSocketHijacking.html
|
||||
let account;
|
||||
try {
|
||||
account = JSON.parse(localStorage.getItem('account'));
|
||||
} catch (e) {
|
||||
localStorage.removeItem('account');
|
||||
}
|
||||
// // handle account auth within the socket itself
|
||||
// // https://www.christian-schneider.net/CrossSiteWebSocketHijacking.html
|
||||
// let account;
|
||||
// try {
|
||||
// account = JSON.parse(localStorage.getItem('account'));
|
||||
// } catch (e) {
|
||||
// localStorage.removeItem('account');
|
||||
// }
|
||||
|
||||
// -------------
|
||||
// Outgoing
|
||||
// -------------
|
||||
function send(msg) {
|
||||
if (msg.method !== 'ping') console.log('outgoing msg', msg);
|
||||
msg.token = account && account.token && account.token;
|
||||
ws.send(cbor.encode(msg));
|
||||
}
|
||||
|
||||
@ -143,29 +142,22 @@ function createSocket(events) {
|
||||
// -------------
|
||||
// Incoming
|
||||
// -------------
|
||||
function accountLogin(res) {
|
||||
const [struct, login] = res;
|
||||
|
||||
account = login;
|
||||
localStorage.setItem('account', JSON.stringify(login));
|
||||
function onAccount(login) {
|
||||
events.setAccount(login);
|
||||
sendAccountConstructs();
|
||||
sendAccountInstances();
|
||||
}
|
||||
|
||||
function accountInstanceList(res) {
|
||||
const [struct, playerList] = res;
|
||||
events.setAccountInstances(playerList);
|
||||
function onAccountInstances(list) {
|
||||
events.setAccountInstances(list);
|
||||
setTimeout(sendAccountInstances, 5000);
|
||||
}
|
||||
|
||||
function accountConstructs(response) {
|
||||
const [structName, constructs] = response;
|
||||
function onAccountConstructs(constructs) {
|
||||
events.setConstructList(constructs);
|
||||
}
|
||||
|
||||
function gameState(response) {
|
||||
const [structName, game] = response;
|
||||
function onGameState(game) {
|
||||
events.setGame(game);
|
||||
}
|
||||
|
||||
@ -180,15 +172,6 @@ function createSocket(events) {
|
||||
clearTimeout(gameStateTimeout);
|
||||
}
|
||||
|
||||
function constructSpawn(response) {
|
||||
const [structName, construct] = response;
|
||||
}
|
||||
|
||||
function zoneState(response) {
|
||||
const [structName, zone] = response;
|
||||
events.setZone(zone);
|
||||
}
|
||||
|
||||
let instanceStateTimeout;
|
||||
function startInstanceStateTimeout(id) {
|
||||
clearTimeout(instanceStateTimeout);
|
||||
@ -196,13 +179,12 @@ function createSocket(events) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function instanceState(response) {
|
||||
const [structName, i] = response;
|
||||
events.setInstance(i);
|
||||
function onInstanceState(instance) {
|
||||
events.setInstance(instance);
|
||||
return true;
|
||||
}
|
||||
|
||||
function instanceList([, list]) {
|
||||
function onOpenInstances(list) {
|
||||
events.setInstanceList(list);
|
||||
return true;
|
||||
}
|
||||
@ -211,14 +193,14 @@ function createSocket(events) {
|
||||
clearTimeout(instanceStateTimeout);
|
||||
}
|
||||
|
||||
function itemInfo(response) {
|
||||
const [structName, info] = response;
|
||||
function onItemInfo(info) {
|
||||
events.setItemInfo(info);
|
||||
}
|
||||
|
||||
function pong() {
|
||||
let pongTimeout;
|
||||
function onPong() {
|
||||
events.setPing(Date.now() - ping);
|
||||
setTimeout(sendPing, 1000);
|
||||
pongTimeout = setTimeout(sendPing, 1000);
|
||||
}
|
||||
|
||||
// -------------
|
||||
@ -228,16 +210,14 @@ function createSocket(events) {
|
||||
// when the server sends a reply it will have one of these message types
|
||||
// this object wraps the reply types to a function
|
||||
const handlers = {
|
||||
construct_spawn: constructSpawn,
|
||||
game_state: gameState,
|
||||
account_login: accountLogin,
|
||||
account_create: accountLogin,
|
||||
account_constructs: accountConstructs,
|
||||
account_instances: accountInstanceList,
|
||||
instance_list: instanceList,
|
||||
instance_state: instanceState,
|
||||
item_info: itemInfo,
|
||||
pong,
|
||||
AccountState: onAccount,
|
||||
AccountConstructs: onAccountConstructs,
|
||||
AccountInstances: onAccountInstances,
|
||||
GameState: onGameState,
|
||||
InstanceState: onInstanceState,
|
||||
ItemInfo: onItemInfo,
|
||||
OpenInstances: onOpenInstances,
|
||||
Pong: onPong,
|
||||
};
|
||||
|
||||
function logout() {
|
||||
@ -249,7 +229,6 @@ function createSocket(events) {
|
||||
function errHandler(error) {
|
||||
switch (error) {
|
||||
case 'invalid token': return logout();
|
||||
case 'no active zone': return sendZoneCreate();
|
||||
case 'no constructs selected': return events.errorPrompt('select_constructs');
|
||||
case 'node requirements not met': return events.errorPrompt('complete_nodes');
|
||||
case 'construct at max skills (4)': return events.errorPrompt('max_skills');
|
||||
@ -265,22 +244,18 @@ function createSocket(events) {
|
||||
// decode binary msg from server
|
||||
const blob = new Uint8Array(event.data);
|
||||
const res = cbor.decode(blob);
|
||||
const { method, params } = res;
|
||||
const [msgType, params] = res;
|
||||
|
||||
if (method !== 'pong' ) console.log(res);
|
||||
if (msgType !== 'Pong') console.log(res);
|
||||
|
||||
// check for error and split into response type and data
|
||||
if (res.err) return errHandler(res.err);
|
||||
if (!handlers[method]) return errorToast(`${method} handler missing`);
|
||||
return handlers[method](params);
|
||||
if (!handlers[msgType]) return errorToast(`${msgType} handler missing`);
|
||||
return handlers[msgType](params);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
ws = new WebSocket(SOCKET_URL);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
// Connection opened
|
||||
ws.addEventListener('open', () => {
|
||||
function onOpen() {
|
||||
toast.info({
|
||||
message: 'connected',
|
||||
position: 'topRight',
|
||||
@ -289,34 +264,42 @@ function createSocket(events) {
|
||||
sendPing();
|
||||
sendItemInfo();
|
||||
|
||||
if (account) {
|
||||
events.setAccount(account);
|
||||
sendAccountInstances();
|
||||
sendInstanceList();
|
||||
sendAccountConstructs();
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Listen for messages
|
||||
ws.addEventListener('message', onMessage);
|
||||
|
||||
ws.addEventListener('error', (event) => {
|
||||
function onError(event) {
|
||||
console.error('WebSocket error', event);
|
||||
// account = null;
|
||||
// return setTimeout(connect, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
ws.addEventListener('close', (event) => {
|
||||
function onClose(event) {
|
||||
console.error('WebSocket closed', event);
|
||||
toast.warning({
|
||||
message: 'disconnected',
|
||||
position: 'topRight',
|
||||
});
|
||||
return setTimeout(connect, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (ws) {
|
||||
clearGameStateTimeout();
|
||||
clearInstanceStateTimeout();
|
||||
clearTimeout(pongTimeout);
|
||||
ws.removeEventListener('open', onOpen);
|
||||
ws.removeEventListener('message', onMessage);
|
||||
ws.removeEventListener('error', onError);
|
||||
ws.removeEventListener('close', onClose);
|
||||
ws = null;
|
||||
}
|
||||
|
||||
ws = new WebSocket(SOCKET_URL);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
// Listen for messages
|
||||
ws.addEventListener('open', onOpen);
|
||||
ws.addEventListener('message', onMessage);
|
||||
ws.addEventListener('error', onError);
|
||||
ws.addEventListener('close', onClose);
|
||||
return ws;
|
||||
}
|
||||
|
||||
|
||||
@ -424,6 +424,24 @@ const removeTier = skill => {
|
||||
};
|
||||
|
||||
|
||||
const SERVER = process.env.NODE_ENV === 'production' ? '/api/' : 'http://localhost:40000/api';
|
||||
function postData(url = '/', data = {}) {
|
||||
// Default options are marked with *
|
||||
return fetch(`${SERVER}${url}`, {
|
||||
method: "POST", // *GET, POST, PUT, DELETE, etc.
|
||||
// mode: "no-cors", // no-cors, cors, *same-origin
|
||||
cache: "no-cache", // *default, no-cache, reload, force-cache, only-if-cached
|
||||
credentials: "include", // include, same-origin, *omit
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
redirect: "error", // manual, *follow, error
|
||||
// referrer: "", // no-referrer, *client
|
||||
body: JSON.stringify(data), // body data type must match "Content-Type" header
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
stringSort,
|
||||
convertItem,
|
||||
@ -431,6 +449,8 @@ module.exports = {
|
||||
eventClasses,
|
||||
getCombatSequence,
|
||||
getCombatText,
|
||||
postData,
|
||||
SERVER,
|
||||
NULL_UUID,
|
||||
STATS,
|
||||
COLOURS,
|
||||
|
||||
@ -2,13 +2,29 @@ exports.up = async knex => {
|
||||
return knex.schema.createTable('accounts', table => {
|
||||
table.uuid('id').primary();
|
||||
table.timestamps(true, true);
|
||||
|
||||
table.string('name', 42).notNullable().unique();
|
||||
table.string('password').notNullable();
|
||||
|
||||
table.string('token', 64).notNullable();
|
||||
table.timestamp('token_expiry').notNullable();
|
||||
|
||||
table.bigInteger('credits')
|
||||
.defaultTo(0)
|
||||
.notNullable();
|
||||
|
||||
table.bool('subscribed')
|
||||
.defaultTo(false)
|
||||
.notNullable();
|
||||
|
||||
table.index('name');
|
||||
table.index('id');
|
||||
});
|
||||
|
||||
await knex.schema.raw(`
|
||||
ALTER TABLE accounts
|
||||
ADD CHECK (credits > 0);
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = async () => {};
|
||||
41
ops/migrations/20190616170750_notifications.js
Normal file
41
ops/migrations/20190616170750_notifications.js
Normal file
@ -0,0 +1,41 @@
|
||||
const notify = `
|
||||
CREATE OR REPLACE FUNCTION notify_event() RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
record RECORD;
|
||||
id UUID;
|
||||
payload JSON;
|
||||
BEGIN
|
||||
IF (TG_OP = 'DELETE') THEN
|
||||
id = OLD.id;
|
||||
ELSE
|
||||
id = NEW.id;
|
||||
END IF;
|
||||
|
||||
payload = json_build_object(
|
||||
'table', TG_TABLE_NAME,
|
||||
'action', TG_OP,
|
||||
'id', id
|
||||
);
|
||||
|
||||
PERFORM pg_notify('events', payload::text);
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
|
||||
$$ LANGUAGE plpgsql;
|
||||
`;
|
||||
|
||||
const trigger = table => `
|
||||
CREATE TRIGGER notify_${table}_event
|
||||
AFTER INSERT OR UPDATE OR DELETE ON ${table}
|
||||
FOR EACH ROW EXECUTE PROCEDURE notify_event();
|
||||
`;
|
||||
|
||||
exports.up = async knex => {
|
||||
await knex.raw(notify);
|
||||
await knex.raw(trigger('accounts'));
|
||||
await knex.raw(trigger('games'));
|
||||
await knex.raw(trigger('instances'));
|
||||
};
|
||||
|
||||
exports.down = async () => {};
|
||||
76
ops/migrations/20190624170147_stripe.js
Normal file
76
ops/migrations/20190624170147_stripe.js
Normal file
@ -0,0 +1,76 @@
|
||||
// INSERT into stripe_customers (account, customer, checkout)
|
||||
// INSERT into stripe_subscriptions (account, customer, checkout, subscription)
|
||||
// INSERT into stripe_purchases (account, customer, checkout, amount)
|
||||
|
||||
exports.up = async knex => {
|
||||
await knex.schema.createTable('stripe_customers', table => {
|
||||
table.string('customer', 128)
|
||||
.primary();
|
||||
|
||||
table.uuid('account')
|
||||
.notNullable()
|
||||
.index();
|
||||
|
||||
table.foreign('account')
|
||||
.references('id')
|
||||
.inTable('accounts')
|
||||
.onDelete('RESTRICT');
|
||||
|
||||
table.string('checkout', 128)
|
||||
.notNullable()
|
||||
.unique();
|
||||
|
||||
table.timestamps(true, true);
|
||||
});
|
||||
|
||||
await knex.schema.createTable('stripe_subscriptions', table => {
|
||||
table.string('subscription', 128)
|
||||
.primary();
|
||||
|
||||
table.uuid('account')
|
||||
.notNullable()
|
||||
.index();
|
||||
|
||||
table.foreign('account')
|
||||
.references('id')
|
||||
.inTable('accounts')
|
||||
.onDelete('RESTRICT');
|
||||
|
||||
table.string('customer', 128)
|
||||
.notNullable();
|
||||
|
||||
table.string('checkout', 128)
|
||||
.notNullable();
|
||||
|
||||
table.timestamps(true, true);
|
||||
});
|
||||
|
||||
await knex.schema.createTable('stripe_purchases', table => {
|
||||
table.string('checkout', 128)
|
||||
.primary();
|
||||
|
||||
table.uuid('account')
|
||||
.notNullable()
|
||||
.index();
|
||||
|
||||
table.foreign('account')
|
||||
.references('id')
|
||||
.inTable('accounts')
|
||||
.onDelete('RESTRICT');
|
||||
|
||||
table.string('customer', 128)
|
||||
.notNullable();
|
||||
|
||||
table.bigInteger('amount')
|
||||
.notNullable();
|
||||
|
||||
table.timestamps(true, true);
|
||||
});
|
||||
|
||||
await knex.schema.raw(`
|
||||
ALTER TABLE stripe_purchases
|
||||
ADD CHECK (amount > 0);
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = async () => {};
|
||||
49
ops/mnml.gg.DEV.nginx.conf
Normal file
49
ops/mnml.gg.DEV.nginx.conf
Normal file
@ -0,0 +1,49 @@
|
||||
upstream mnml_dev {
|
||||
server 0.0.0.0:41337;
|
||||
}
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
# DEV
|
||||
server {
|
||||
root /home/git/mnml/client/dist/;
|
||||
index index.html;
|
||||
|
||||
server_name dev.mnml.gg; # managed by Certbot
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
listen [::]:443;
|
||||
ssl on;
|
||||
listen 443 ssl; # managed by Certbot
|
||||
ssl_certificate /etc/letsencrypt/live/dev.mnml.gg/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/dev.mnml.gg/privkey.pem; # managed by Certbot
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
|
||||
location /api/ws {
|
||||
proxy_pass http://mnml_dev;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://mnml_dev;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
# http -> https
|
||||
server {
|
||||
server_name dev.mnml.gg;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ map $http_upgrade $connection_upgrade {
|
||||
'' close;
|
||||
}
|
||||
|
||||
# PRODUCTION
|
||||
server {
|
||||
root /home/git/mnml/client/dist/;
|
||||
index index.html;
|
||||
@ -9,19 +9,31 @@ uuid = { version = "0.5", features = ["serde", "v4"] }
|
||||
serde = "1"
|
||||
serde_derive = "1"
|
||||
serde_cbor = "0.9"
|
||||
serde_json = "1.0"
|
||||
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
tungstenite = "0.6"
|
||||
bcrypt = "0.2"
|
||||
|
||||
dotenv = "0.9.0"
|
||||
postgres = { version = "0.15", features = ["with-uuid", "with-chrono"] }
|
||||
r2d2 = "*"
|
||||
r2d2_postgres = "*"
|
||||
fallible-iterator = "0.1"
|
||||
|
||||
failure = "0.1"
|
||||
|
||||
log = "0.4"
|
||||
fern = "0.5"
|
||||
|
||||
actix = "0.8.2"
|
||||
actix-web = "1.0.0"
|
||||
actix-web-actors = "1.0.0"
|
||||
actix-cors = "0.1.0"
|
||||
|
||||
stripe-rust = { version = "0.10.4", features = ["webhooks"] }
|
||||
|
||||
[patch.crates-io]
|
||||
# stripe-rust = { git = "https://github.com/margh/stripe-rs.git" }
|
||||
|
||||
stripe-rust = { path = "/home/ntr/code/stripe-rs" }
|
||||
|
||||
@ -3,17 +3,16 @@ use bcrypt::{hash, verify};
|
||||
use rand::{thread_rng, Rng};
|
||||
use rand::distributions::Alphanumeric;
|
||||
use std::iter;
|
||||
use std::convert::TryFrom;
|
||||
use serde_cbor::{from_slice};
|
||||
|
||||
use postgres::transaction::Transaction;
|
||||
|
||||
use rpc::{AccountCreateParams, AccountLoginParams};
|
||||
|
||||
use construct::{Construct, construct_recover};
|
||||
use instance::{Instance, instance_delete};
|
||||
|
||||
use failure::Error;
|
||||
use failure::err_msg;
|
||||
use failure::{err_msg, format_err};
|
||||
|
||||
static PASSWORD_MIN_LEN: usize = 11;
|
||||
|
||||
@ -21,60 +20,193 @@ static PASSWORD_MIN_LEN: usize = 11;
|
||||
pub struct Account {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
token: String,
|
||||
pub credits: u32,
|
||||
pub subscribed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug,Clone,Serialize,Deserialize)]
|
||||
struct AccountEntry {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
password: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
// MAYBE
|
||||
// hash tokens with a secret
|
||||
pub fn account_from_token(token: String, tx: &mut Transaction) -> Result<Account, Error> {
|
||||
impl Account {
|
||||
pub fn select(tx: &mut Transaction, id: Uuid) -> Result<Account, Error> {
|
||||
let query = "
|
||||
SELECT id, name, token
|
||||
SELECT id, name, credits, subscribed
|
||||
FROM accounts
|
||||
WHERE token = $1;
|
||||
WHERE id = $1;
|
||||
";
|
||||
|
||||
let result = tx
|
||||
.query(query, &[&id])?;
|
||||
|
||||
let row = result.iter().next()
|
||||
.ok_or(format_err!("account not found {:?}", id))?;
|
||||
|
||||
let db_credits: i64 = row.get(2);
|
||||
let credits = u32::try_from(db_credits)
|
||||
.or(Err(format_err!("user {:?} has unparsable balance {:?}", id, db_credits)))?;
|
||||
|
||||
let subscribed: bool = row.get(3);
|
||||
|
||||
Ok(Account { id, name: row.get(1), credits, subscribed })
|
||||
}
|
||||
|
||||
pub fn from_token(tx: &mut Transaction, token: String) -> Result<Account, Error> {
|
||||
let query = "
|
||||
SELECT id, name, subscribed, credits
|
||||
FROM accounts
|
||||
WHERE token = $1
|
||||
AND token_expiry > now();
|
||||
";
|
||||
|
||||
let result = tx
|
||||
.query(query, &[&token])?;
|
||||
|
||||
let returned = match result.iter().next() {
|
||||
let row = result.iter().next()
|
||||
.ok_or(err_msg("invalid token"))?;
|
||||
|
||||
let id: Uuid = row.get(0);
|
||||
let name: String = row.get(1);
|
||||
let subscribed: bool = row.get(2);
|
||||
let db_credits: i64 = row.get(3);
|
||||
|
||||
let credits = u32::try_from(db_credits)
|
||||
.or(Err(format_err!("user {:?} has unparsable balance {:?}", id, db_credits)))?;
|
||||
|
||||
Ok(Account { id, name, credits, subscribed })
|
||||
}
|
||||
|
||||
pub fn login(tx: &mut Transaction, name: &String, password: &String) -> Result<Account, Error> {
|
||||
let query = "
|
||||
SELECT id, password, name, credits, subscribed
|
||||
FROM accounts
|
||||
WHERE name = $1
|
||||
";
|
||||
|
||||
let result = tx
|
||||
.query(query, &[&name])?;
|
||||
|
||||
let row = match result.iter().next() {
|
||||
Some(row) => row,
|
||||
None => return Err(err_msg("invalid token")),
|
||||
None => {
|
||||
let mut rng = thread_rng();
|
||||
let garbage: String = iter::repeat(())
|
||||
.map(|()| rng.sample(Alphanumeric))
|
||||
.take(64)
|
||||
.collect();
|
||||
|
||||
// verify garbage to prevent timing attacks
|
||||
verify(garbage.clone(), &garbage).ok();
|
||||
return Err(err_msg("account not found"));
|
||||
},
|
||||
};
|
||||
|
||||
let entry = Account {
|
||||
id: returned.get(0),
|
||||
name: returned.get(1),
|
||||
token: returned.get(2),
|
||||
};
|
||||
let id: Uuid = row.get(0);
|
||||
let hash: String = row.get(1);
|
||||
let name: String = row.get(2);
|
||||
let db_credits: i64 = row.get(3);
|
||||
let subscribed: bool = row.get(4);
|
||||
|
||||
if !verify(password, &hash)? {
|
||||
return Err(err_msg("password does not match"));
|
||||
}
|
||||
|
||||
let credits = u32::try_from(db_credits)
|
||||
.or(Err(format_err!("user {:?} has unparsable balance {:?}", id, db_credits)))?;
|
||||
|
||||
Ok(Account { id, name, credits, subscribed })
|
||||
}
|
||||
|
||||
pub fn new_token(tx: &mut Transaction, id: Uuid) -> Result<String, Error> {
|
||||
let mut rng = thread_rng();
|
||||
let token: String = iter::repeat(())
|
||||
.map(|()| rng.sample(Alphanumeric))
|
||||
.take(64)
|
||||
.collect();
|
||||
|
||||
// update token
|
||||
let query = "
|
||||
UPDATE accounts
|
||||
SET token = $1, updated_at = now(), token_expiry = now() + interval '1 week'
|
||||
WHERE id = $2
|
||||
RETURNING id, name;
|
||||
";
|
||||
|
||||
let result = tx
|
||||
.query(query, &[&token, &id])?;
|
||||
|
||||
let row = result.iter().next()
|
||||
.ok_or(format_err!("account not updated {:?}", id))?;
|
||||
|
||||
let name: String = row.get(1);
|
||||
|
||||
info!("login account={:?}", name);
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub fn add_credits(tx: &mut Transaction, id: Uuid, credits: i64) -> Result<String, Error> {
|
||||
let query = "
|
||||
UPDATE accounts
|
||||
SET credits = credits + $1
|
||||
WHERE id = $2
|
||||
RETURNING credits, name;
|
||||
";
|
||||
|
||||
let result = tx
|
||||
.query(query, &[&credits, &id])?;
|
||||
|
||||
let row = result.iter().next()
|
||||
.ok_or(format_err!("account not updated {:?}", id))?;
|
||||
|
||||
println!("{:?}", row);
|
||||
|
||||
let db_credits: i64 = row.get(0);
|
||||
let total = u32::try_from(db_credits)
|
||||
.or(Err(format_err!("user {:?} has unparsable balance {:?}", id, db_credits)))?;
|
||||
|
||||
let name: String = row.get(1);
|
||||
|
||||
info!("account credited name={:?} credited={:?} total={:?}", name, credits, total);
|
||||
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
pub fn set_subscribed(tx: &mut Transaction, id: Uuid, subscribed: bool) -> Result<String, Error> {
|
||||
let query = "
|
||||
UPDATE accounts
|
||||
SET subscribed = $1
|
||||
WHERE id = $2
|
||||
RETURNING name;
|
||||
";
|
||||
|
||||
let result = tx
|
||||
.query(query, &[&subscribed, &id])?;
|
||||
|
||||
let row = result.iter().next()
|
||||
.ok_or(format_err!("account not updated {:?}", id))?;
|
||||
|
||||
let name: String = row.get(0);
|
||||
|
||||
info!("account subscription status updated name={:?} subscribed={:?}", name, subscribed);
|
||||
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
return Ok(entry);
|
||||
}
|
||||
|
||||
pub fn account_create(params: AccountCreateParams, tx: &mut Transaction) -> Result<Account, Error> {
|
||||
let id = Uuid::new_v4();
|
||||
|
||||
if params.password.len() < PASSWORD_MIN_LEN {
|
||||
pub fn account_create(name: &String, password: &String, code: &String, tx: &mut Transaction) -> Result<String, Error> {
|
||||
if password.len() < PASSWORD_MIN_LEN {
|
||||
return Err(err_msg("password must be at least 12 characters"));
|
||||
}
|
||||
|
||||
if params.code.to_lowercase() != "grep842" {
|
||||
if code.to_lowercase() != "grep842" {
|
||||
return Err(err_msg("https://discord.gg/YJJgurM"));
|
||||
}
|
||||
|
||||
if params.name.len() == 0 {
|
||||
if name.len() == 0 {
|
||||
return Err(err_msg("account name not supplied"));
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
let rounds = 8;
|
||||
let password = hash(¶ms.password, rounds)?;
|
||||
let password = hash(&password, rounds)?;
|
||||
|
||||
let mut rng = thread_rng();
|
||||
let token: String = iter::repeat(())
|
||||
@ -82,77 +214,23 @@ pub fn account_create(params: AccountCreateParams, tx: &mut Transaction) -> Resu
|
||||
.take(64)
|
||||
.collect();
|
||||
|
||||
let account = AccountEntry {
|
||||
name: params.name,
|
||||
id,
|
||||
password,
|
||||
token,
|
||||
};
|
||||
|
||||
let query = "
|
||||
INSERT INTO accounts (id, name, password, token)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, name, token;
|
||||
";
|
||||
|
||||
|
||||
let result = tx
|
||||
.query(query, &[&account.id, &account.name, &account.password, &account.token])?;
|
||||
|
||||
let returned = result.iter().next().expect("no row returned");
|
||||
|
||||
let entry = Account {
|
||||
id: returned.get(0),
|
||||
name: returned.get(1),
|
||||
token: returned.get(2),
|
||||
};
|
||||
|
||||
info!("registration account={:?}", entry.name);
|
||||
|
||||
return Ok(entry);
|
||||
}
|
||||
|
||||
pub fn account_login(params: AccountLoginParams, tx: &mut Transaction) -> Result<Account, Error> {
|
||||
let query = "
|
||||
SELECT id, name, token, password
|
||||
FROM accounts
|
||||
WHERE name = $1;
|
||||
INSERT INTO accounts (id, name, password, token, token_expiry)
|
||||
VALUES ($1, $2, $3, $4, now() + interval '1 week')
|
||||
RETURNING id, name;
|
||||
";
|
||||
|
||||
let result = tx
|
||||
.query(query, &[¶ms.name])?;
|
||||
.query(query, &[&id, &name, &password, &token])?;
|
||||
|
||||
let returned = match result.iter().next() {
|
||||
match result.iter().next() {
|
||||
Some(row) => row,
|
||||
// MAYBE
|
||||
// verify gibberish to delay response for timing attacks
|
||||
None => return Err(err_msg("account not found")),
|
||||
None => return Err(err_msg("account not created")),
|
||||
};
|
||||
|
||||
let entry = AccountEntry {
|
||||
id: returned.get(0),
|
||||
name: returned.get(1),
|
||||
token: returned.get(2),
|
||||
password: returned.get(3),
|
||||
};
|
||||
info!("registration account={:?}", name);
|
||||
|
||||
if !verify(¶ms.password, &entry.password)? {
|
||||
return Err(err_msg("password does not match"));
|
||||
}
|
||||
|
||||
info!("login account={:?}", entry.name);
|
||||
|
||||
// MAYBE
|
||||
// update token?
|
||||
// don't necessarily want to log every session out when logging in
|
||||
|
||||
let account = Account {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
token: entry.token,
|
||||
};
|
||||
|
||||
return Ok(account);
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub fn account_constructs(tx: &mut Transaction, account: &Account) -> Result<Vec<Construct>, Error> {
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
extern crate rand;
|
||||
extern crate uuid;
|
||||
extern crate tungstenite;
|
||||
extern crate bcrypt;
|
||||
extern crate chrono;
|
||||
|
||||
@ -8,6 +7,12 @@ extern crate dotenv;
|
||||
extern crate postgres;
|
||||
extern crate r2d2;
|
||||
extern crate r2d2_postgres;
|
||||
extern crate fallible_iterator;
|
||||
|
||||
extern crate actix;
|
||||
extern crate actix_cors;
|
||||
extern crate actix_web;
|
||||
extern crate actix_web_actors;
|
||||
|
||||
extern crate serde;
|
||||
extern crate serde_cbor;
|
||||
@ -17,22 +22,28 @@ extern crate serde_cbor;
|
||||
extern crate fern;
|
||||
#[macro_use] extern crate log;
|
||||
|
||||
extern crate stripe;
|
||||
|
||||
mod account;
|
||||
mod construct;
|
||||
mod effect;
|
||||
mod game;
|
||||
mod instance;
|
||||
mod item;
|
||||
mod mob;
|
||||
mod mtx;
|
||||
mod names;
|
||||
mod net;
|
||||
mod payments;
|
||||
mod player;
|
||||
mod pubsub;
|
||||
mod rpc;
|
||||
mod skill;
|
||||
mod effect;
|
||||
mod spec;
|
||||
mod util;
|
||||
mod vbox;
|
||||
mod warden;
|
||||
mod ws;
|
||||
|
||||
use dotenv::dotenv;
|
||||
use net::{start};
|
||||
@ -49,7 +60,7 @@ fn setup_logger() -> Result<(), fern::InitError> {
|
||||
))
|
||||
})
|
||||
.level_for("postgres", log::LevelFilter::Info)
|
||||
.level_for("tungstenite", log::LevelFilter::Info)
|
||||
.level_for("actix_web", log::LevelFilter::Info)
|
||||
.level(log::LevelFilter::Info)
|
||||
.chain(std::io::stdout())
|
||||
.chain(fern::log_file("log/mnml.log")?)
|
||||
|
||||
137
server/src/mtx.rs
Normal file
137
server/src/mtx.rs
Normal file
@ -0,0 +1,137 @@
|
||||
use uuid::Uuid;
|
||||
// use rand::prelude::*;
|
||||
|
||||
use serde_cbor::{from_slice};
|
||||
use postgres::transaction::Transaction;
|
||||
|
||||
use failure::Error;
|
||||
use failure::err_msg;
|
||||
|
||||
#[derive(Debug,Copy,Clone,Serialize,Deserialize)]
|
||||
pub enum MtxVariant {
|
||||
ArchitectureMolecular,
|
||||
ArchitectureInvader,
|
||||
}
|
||||
|
||||
impl MtxVariant {
|
||||
fn new(self, account: Uuid) -> Mtx {
|
||||
match self {
|
||||
MtxVariant::ArchitectureInvader => Mtx { id: Uuid::new_v4(), account, variant: self },
|
||||
MtxVariant::ArchitectureMolecular => Mtx { id: Uuid::new_v4(), account, variant: self },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug,Copy,Clone,Serialize,Deserialize)]
|
||||
pub struct Mtx {
|
||||
id: Uuid,
|
||||
account: Uuid,
|
||||
variant: MtxVariant,
|
||||
}
|
||||
|
||||
impl Mtx {
|
||||
pub fn account_list(tx: &mut Transaction, account: Uuid) -> Result<Vec<Mtx>, Error> {
|
||||
let query = "
|
||||
SELECT data, id
|
||||
FROM mtx
|
||||
WHERE account = $1
|
||||
FOR UPDATE;
|
||||
";
|
||||
|
||||
let result = tx
|
||||
.query(query, &[&account])?;
|
||||
|
||||
let values = result.into_iter().filter_map(|row| {
|
||||
let bytes: Vec<u8> = row.get(0);
|
||||
// let id: Uuid = row.get(1);
|
||||
|
||||
match from_slice::<Mtx>(&bytes) {
|
||||
Ok(i) => Some(i),
|
||||
Err(e) => {
|
||||
warn!("{:?}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}).collect::<Vec<Mtx>>();
|
||||
|
||||
return Ok(values);
|
||||
}
|
||||
|
||||
pub fn delete(tx: &mut Transaction, id: Uuid) -> Result<(), Error> {
|
||||
let query = "
|
||||
DELETE
|
||||
FROM mtx
|
||||
WHERE id = $1;
|
||||
";
|
||||
|
||||
let result = tx
|
||||
.execute(query, &[&id])?;
|
||||
|
||||
if result != 1 {
|
||||
return Err(format_err!("unable to delete mtx {:?}", id));
|
||||
}
|
||||
|
||||
info!("mtx deleted {:?}", id);
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
pub fn insert(&self, tx: &mut Transaction) -> Result<&Mtx, Error> {
|
||||
let query = "
|
||||
INSERT INTO mtx (id, account, variant)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, account;
|
||||
";
|
||||
|
||||
let result = tx
|
||||
.query(query, &[&self.id, &self.account, &format!("{:?}", self.variant)])?;
|
||||
|
||||
result.iter().next().ok_or(err_msg("mtx not written"))?;
|
||||
|
||||
info!("wrote mtx {:?}", self);
|
||||
|
||||
return Ok(self);
|
||||
}
|
||||
|
||||
// pub fn update(&self, tx: &mut Transaction) -> Result<&Mtx, Error> {
|
||||
// let query = "
|
||||
// UPDATE mtx
|
||||
// SET data = $1, updated_at = now()
|
||||
// WHERE id = $2
|
||||
// RETURNING id, data;
|
||||
// ";
|
||||
|
||||
// let result = tx
|
||||
// .query(query, &[&self.id, &to_vec(self)?])?;
|
||||
|
||||
// if let None = result.iter().next() {
|
||||
// return Err(err_msg("mtx not written"));
|
||||
// }
|
||||
|
||||
// info!("wrote mtx {:?}", self);
|
||||
|
||||
// return Ok(self);
|
||||
// }
|
||||
|
||||
pub fn select(tx: &mut Transaction, id: Uuid, account: Uuid) -> Result<Option<Mtx>, Error> {
|
||||
let query = "
|
||||
SELECT data, id
|
||||
FROM mtx
|
||||
WHERE account = $1
|
||||
AND id = $2
|
||||
FOR UPDATE;
|
||||
";
|
||||
|
||||
let result = tx
|
||||
.query(query, &[&account, &id])?;
|
||||
|
||||
if let Some(row) = result.iter().next() {
|
||||
let bytes: Vec<u8> = row.get(0);
|
||||
Ok(Some(from_slice::<Mtx>(&bytes)?))
|
||||
} else {
|
||||
Err(format_err!("mtx not found {:?}", id))
|
||||
}
|
||||
}
|
||||
|
||||
// actual impl
|
||||
}
|
||||
@ -1,49 +1,135 @@
|
||||
use failure::{Error, err_msg};
|
||||
use tungstenite::Message;
|
||||
use tungstenite::protocol::WebSocket;
|
||||
use tungstenite::server::accept;
|
||||
use tungstenite::Message::Binary;
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use serde_cbor::{to_vec};
|
||||
|
||||
use std::env;
|
||||
use std::thread::{spawn, sleep};
|
||||
use std::time::{Instant, Duration};
|
||||
use std::time::{Duration};
|
||||
|
||||
use actix_web::{middleware, web, App, HttpMessage, HttpRequest, HttpResponse, HttpServer};
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::{Cookie};
|
||||
use actix_web::cookie::{SameSite};
|
||||
use actix_cors::Cors;
|
||||
|
||||
use r2d2::{Pool};
|
||||
use r2d2::{PooledConnection};
|
||||
use r2d2_postgres::{TlsMode, PostgresConnectionManager};
|
||||
|
||||
static DB_POOL_SIZE: u32 = 20;
|
||||
use rpc::{RpcErrorResponse, AccountLoginParams, AccountCreateParams};
|
||||
use warden::{warden};
|
||||
use pubsub::{pg_listen};
|
||||
use ws::{connect};
|
||||
use account::{Account, account_create};
|
||||
use payments::{post_stripe_event};
|
||||
|
||||
pub type Db = PooledConnection<PostgresConnectionManager>;
|
||||
pub type PgPool = Pool<PostgresConnectionManager>;
|
||||
|
||||
use rpc::{Rpc};
|
||||
use warden::{warden};
|
||||
const DB_POOL_SIZE: u32 = 20;
|
||||
|
||||
#[derive(Debug,Clone,Serialize,Deserialize)]
|
||||
struct RpcErrorResponse {
|
||||
err: String
|
||||
#[derive(Fail, Debug)]
|
||||
pub enum MnmlHttpError {
|
||||
// User Facing Errors
|
||||
#[fail(display="internal server error")]
|
||||
ServerError,
|
||||
#[fail(display="unauthorized")]
|
||||
Unauthorized,
|
||||
#[fail(display="bad request")]
|
||||
BadRequest,
|
||||
}
|
||||
|
||||
fn receive(db: Db, begin: Instant, rpc: &Rpc, msg: Message, client: &mut WebSocket<TcpStream>) -> Result<String, Error> {
|
||||
match rpc.receive(msg, begin, &db, client) {
|
||||
Ok(reply) => {
|
||||
let response = to_vec(&reply)
|
||||
.expect("failed to serialize response");
|
||||
client.write_message(Binary(response))?;
|
||||
return Ok(reply.method);
|
||||
impl ResponseError for MnmlHttpError {
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
match *self {
|
||||
MnmlHttpError::ServerError => HttpResponse::InternalServerError()
|
||||
.json(RpcErrorResponse { err: self.to_string() }),
|
||||
|
||||
MnmlHttpError::BadRequest => HttpResponse::BadRequest()
|
||||
.json(RpcErrorResponse { err: self.to_string() }),
|
||||
|
||||
MnmlHttpError::Unauthorized => HttpResponse::Unauthorized()
|
||||
.cookie(Cookie::build("x-auth-token", "")
|
||||
// .secure(secure)
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Strict)
|
||||
.max_age(-1) // 1 week aligns with db set
|
||||
.finish())
|
||||
.json(RpcErrorResponse { err: self.to_string() }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn login_res(token: String) -> HttpResponse {
|
||||
HttpResponse::Ok()
|
||||
.cookie(Cookie::build("x-auth-token", token)
|
||||
// .secure(secure)
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Strict)
|
||||
.max_age(60 * 60 * 24 * 7) // 1 week aligns with db set
|
||||
.finish())
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn logout_res() -> HttpResponse {
|
||||
HttpResponse::Ok()
|
||||
.cookie(Cookie::build("x-auth-token", "")
|
||||
// .secure(secure)
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Strict)
|
||||
.max_age(-1)
|
||||
.finish())
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn login(state: web::Data<State>, params: web::Json::<AccountLoginParams>) -> Result<HttpResponse, MnmlHttpError> {
|
||||
let db = state.pool.get().or(Err(MnmlHttpError::ServerError))?;
|
||||
let mut tx = db.transaction().or(Err(MnmlHttpError::ServerError))?;
|
||||
|
||||
match Account::login(&mut tx, ¶ms.name, ¶ms.password) {
|
||||
Ok(a) => {
|
||||
let token = Account::new_token(&mut tx, a.id).or(Err(MnmlHttpError::ServerError))?;
|
||||
tx.commit().or(Err(MnmlHttpError::ServerError))?;
|
||||
Ok(login_res(token))
|
||||
},
|
||||
Err(e) => {
|
||||
let response = to_vec(&RpcErrorResponse { err: e.to_string() })
|
||||
.expect("failed to serialize error response");
|
||||
client.write_message(Binary(response))?;
|
||||
return Err(err_msg(e));
|
||||
info!("{:?}", e);
|
||||
Err(MnmlHttpError::Unauthorized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn db_connection(url: String) -> Pool<PostgresConnectionManager> {
|
||||
fn logout(r: HttpRequest, state: web::Data<State>) -> Result<HttpResponse, MnmlHttpError> {
|
||||
match r.cookie("x-auth-token") {
|
||||
Some(t) => {
|
||||
let db = state.pool.get().or(Err(MnmlHttpError::ServerError))?;
|
||||
let mut tx = db.transaction().or(Err(MnmlHttpError::ServerError))?;
|
||||
match Account::from_token(&mut tx, t.value().to_string()) {
|
||||
Ok(a) => {
|
||||
Account::new_token(&mut tx, a.id).or(Err(MnmlHttpError::Unauthorized))?;
|
||||
tx.commit().or(Err(MnmlHttpError::ServerError))?;
|
||||
return Ok(logout_res());
|
||||
},
|
||||
Err(_) => Err(MnmlHttpError::Unauthorized),
|
||||
}
|
||||
},
|
||||
None => Err(MnmlHttpError::Unauthorized),
|
||||
}
|
||||
}
|
||||
|
||||
fn register(state: web::Data<State>, params: web::Json::<AccountCreateParams>) -> Result<HttpResponse, MnmlHttpError> {
|
||||
let db = state.pool.get().or(Err(MnmlHttpError::ServerError))?;
|
||||
let mut tx = db.transaction().or(Err(MnmlHttpError::ServerError))?;
|
||||
|
||||
match account_create(¶ms.name, ¶ms.password, ¶ms.code, &mut tx) {
|
||||
Ok(token) => {
|
||||
tx.commit().or(Err(MnmlHttpError::ServerError))?;
|
||||
Ok(login_res(token))
|
||||
},
|
||||
Err(e) => {
|
||||
info!("{:?}", e);
|
||||
Err(MnmlHttpError::BadRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_pool(url: String) -> Pool<PostgresConnectionManager> {
|
||||
let manager = PostgresConnectionManager::new(url, TlsMode::None)
|
||||
.expect("could not instantiate pg manager");
|
||||
|
||||
@ -53,40 +139,15 @@ pub fn db_connection(url: String) -> Pool<PostgresConnectionManager> {
|
||||
.expect("Failed to create pool.")
|
||||
}
|
||||
|
||||
// fn print_panic_payload(ctx: &str, payload: &(Any + Send + 'static)) {
|
||||
// let d = format!("{:?}", payload);
|
||||
// let s = if let Some(s) = payload.downcast_ref::<String>() {
|
||||
// &s
|
||||
// } else if let Some(s) = payload.downcast_ref::<&str>() {
|
||||
// s
|
||||
// } else {
|
||||
// // "PAYLOAD IS NOT A STRING"
|
||||
// d.as_str()
|
||||
// };
|
||||
// info!("{}: PANIC OCCURRED: {}", ctx, s);
|
||||
// }
|
||||
pub struct State {
|
||||
pub pool: PgPool,
|
||||
// pub pubsub: PubSub,
|
||||
}
|
||||
|
||||
pub fn start() {
|
||||
// panic::set_hook(Box::new(|panic_info| {
|
||||
// print_panic_payload("set_hook", panic_info.payload());
|
||||
// if let Some(location) = panic_info.location() {
|
||||
// info!("LOCATION: {}:{}", location.file(), location.line());
|
||||
// } else {
|
||||
// info!("NO LOCATION INFORMATION");
|
||||
// }
|
||||
// }));
|
||||
|
||||
let database_url = env::var("DATABASE_URL")
|
||||
.expect("DATABASE_URL must be set");
|
||||
|
||||
let pool = db_connection(database_url);
|
||||
|
||||
// {
|
||||
// let startup_connection = pool.get().expect("unable to get db connection");
|
||||
// startup(startup_connection).unwrap();
|
||||
// }
|
||||
|
||||
let server = TcpListener::bind("0.0.0.0:40000").unwrap();
|
||||
let pool = create_pool(database_url);
|
||||
|
||||
let warden_pool = pool.clone();
|
||||
spawn(move || {
|
||||
@ -97,32 +158,32 @@ pub fn start() {
|
||||
}
|
||||
sleep(Duration::new(1, 0));
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
for stream in server.incoming() {
|
||||
let db = pool.clone();
|
||||
spawn(move || {
|
||||
let mut websocket = accept(stream.unwrap()).unwrap();
|
||||
let rpc = Rpc {};
|
||||
|
||||
loop {
|
||||
match websocket.read_message() {
|
||||
Ok(msg) => {
|
||||
let begin = Instant::now();
|
||||
let db_connection = db.get().expect("unable to get db connection");
|
||||
match receive(db_connection, begin, &rpc, msg, &mut websocket) {
|
||||
Ok(_) => (),
|
||||
Err(e) => warn!("{:?}", e),
|
||||
}
|
||||
},
|
||||
// connection is closed
|
||||
Err(e) => {
|
||||
debug!("{:?}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let pubsub_pool = pool.clone();
|
||||
spawn(move || loop {
|
||||
let pubsub_conn = pubsub_pool.get().expect("could not get pubsub pg connection");
|
||||
match pg_listen(pubsub_conn) {
|
||||
Ok(_) => warn!("pg listen closed"),
|
||||
Err(e) => warn!("pg_listen error {:?}", e),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
HttpServer::new(move || App::new()
|
||||
.data(State { pool: pool.clone() })
|
||||
.wrap(middleware::Logger::default())
|
||||
.wrap(Cors::new().supports_credentials())
|
||||
.service(web::resource("/api/login").route(web::post().to(login)))
|
||||
.service(web::resource("/api/logout").route(web::post().to(logout)))
|
||||
.service(web::resource("/api/register").route(web::post().to(register)))
|
||||
|
||||
.service(web::resource("/api/payments/stripe")
|
||||
.route(web::post().to(post_stripe_event)))
|
||||
|
||||
// .service(web::resource("/api/payments/crypto")
|
||||
// .route(web::post().to(post_stripe_payment)))
|
||||
|
||||
.service(web::resource("/api/ws").route(web::get().to(connect))))
|
||||
.bind("127.0.0.1:40000").expect("could not bind to port")
|
||||
.run().expect("could not start http server");
|
||||
}
|
||||
|
||||
263
server/src/payments.rs
Normal file
263
server/src/payments.rs
Normal file
@ -0,0 +1,263 @@
|
||||
use uuid::Uuid;
|
||||
use actix_web::{web, HttpResponse};
|
||||
use postgres::transaction::Transaction;
|
||||
|
||||
use failure::Error;
|
||||
use failure::err_msg;
|
||||
|
||||
use stripe::{Event, EventObject, CheckoutSession, SubscriptionStatus};
|
||||
|
||||
use net::{State, PgPool, MnmlHttpError};
|
||||
use account::{Account};
|
||||
|
||||
pub fn subscription_account(tx: &mut Transaction, sub: String) -> Result<Uuid, Error> {
|
||||
let query = "
|
||||
SELECT account
|
||||
FROM stripe_subscriptions
|
||||
WHERE subscription = $1;
|
||||
";
|
||||
|
||||
let result = tx
|
||||
.query(query, &[&sub])?;
|
||||
|
||||
let row = result.iter().next()
|
||||
.ok_or(err_msg("user not subscribed"))?;
|
||||
|
||||
Ok(row.get(0))
|
||||
}
|
||||
|
||||
// we use i64 because it is converted to BIGINT for pg
|
||||
// and we can losslessly pull it into u32 which is big
|
||||
// enough for the ballers
|
||||
const CREDITS_COST_CENTS: i64 = 10;
|
||||
const CREDITS_SUB_BONUS: i64 = 40;
|
||||
|
||||
// Because the client_reference_id (account.id) is only included
|
||||
// in the stripe CheckoutSession object
|
||||
// we ensure that we store each object in pg with a link to the object
|
||||
// and to the account id in case of refunds
|
||||
#[derive(Debug,Clone,Serialize,Deserialize)]
|
||||
enum StripeData {
|
||||
Customer { account: Uuid, customer: String, checkout: String },
|
||||
Subscription { account: Uuid, customer: String, checkout: String, subscription: String, },
|
||||
|
||||
// i64 used because it converts to psql BIGINT
|
||||
// expecting a similar system to be used for eth amounts
|
||||
Purchase { account: Uuid, amount: i64, customer: String, checkout: String },
|
||||
}
|
||||
|
||||
impl StripeData {
|
||||
fn insert(&self, tx: &mut Transaction) -> Result<&StripeData, Error> {
|
||||
match self {
|
||||
StripeData::Customer { account, customer, checkout } => {
|
||||
tx.execute("
|
||||
INSERT into stripe_customers (account, customer, checkout)
|
||||
VALUES ($1, $2, $3);
|
||||
", &[&account, &customer, &checkout])?;
|
||||
info!("new stripe customer {:?}", self);
|
||||
Ok(self)
|
||||
},
|
||||
|
||||
StripeData::Subscription { account, customer, checkout, subscription } => {
|
||||
tx.execute("
|
||||
INSERT into stripe_subscriptions (account, customer, checkout, subscription)
|
||||
VALUES ($1, $2, $3, $4);
|
||||
", &[&account, &customer, &checkout, &subscription])?;
|
||||
info!("new stripe subscription {:?}", self);
|
||||
Ok(self)
|
||||
},
|
||||
|
||||
StripeData::Purchase { account, amount, customer, checkout } => {
|
||||
tx.execute("
|
||||
INSERT into stripe_purchases (account, customer, checkout, amount)
|
||||
VALUES ($1, $2, $3, $4);
|
||||
", &[&account, &customer, &checkout, amount])?;
|
||||
info!("new stripe purchase {:?}", self);
|
||||
Ok(self)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn side_effects(&self, tx: &mut Transaction) -> Result<&StripeData, Error> {
|
||||
match self {
|
||||
// when we get a subscription we just immediately set the user to be subbed
|
||||
// so we don't have to deal with going to fetch all the details from
|
||||
// stripe just to double check
|
||||
// update webhooks will tell us when the subscription changes
|
||||
// see EventObject::Subscription handler below
|
||||
StripeData::Subscription { subscription: _, account, customer: _, checkout: _ } => {
|
||||
Account::add_credits(tx, *account, CREDITS_SUB_BONUS)?;
|
||||
Account::set_subscribed(tx, *account, true)?;
|
||||
Ok(self)
|
||||
},
|
||||
StripeData::Purchase { account, customer: _, amount, checkout: _ } => {
|
||||
let credits = amount
|
||||
.checked_div(CREDITS_COST_CENTS)
|
||||
.expect("credits cost 0");
|
||||
|
||||
Account::add_credits(tx, *account, credits)?;
|
||||
|
||||
Ok(self)
|
||||
},
|
||||
_ => Ok(self),
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fn stripe_checkout_data(session: CheckoutSession) -> Result<Vec<StripeData>, Error> {
|
||||
let account = match session.client_reference_id {
|
||||
Some(ref a) => Uuid::parse_str(a)?,
|
||||
None => {
|
||||
error!("unknown user checkout {:?}", session);
|
||||
return Err(err_msg("NoUser"))
|
||||
},
|
||||
};
|
||||
|
||||
let mut items = vec![];
|
||||
let customer = session.customer.ok_or(err_msg("UnknownCustomer"))?;
|
||||
let checkout = session.id;
|
||||
|
||||
items.push(StripeData::Customer { account, customer: customer.id().to_string(), checkout: checkout.to_string() });
|
||||
|
||||
if let Some(sub) = session.subscription {
|
||||
items.push(StripeData::Subscription {
|
||||
account,
|
||||
customer: customer.id().to_string(),
|
||||
checkout: checkout.to_string(),
|
||||
subscription: sub.id().to_string()
|
||||
});
|
||||
}
|
||||
|
||||
for item in session.display_items.into_iter() {
|
||||
let amount = item.amount.ok_or(err_msg("NoPricePurchase"))? as i64;
|
||||
items.push(StripeData::Purchase { account, amount, customer: customer.id().to_string(), checkout: checkout.to_string() });
|
||||
};
|
||||
|
||||
return Ok(items);
|
||||
}
|
||||
|
||||
fn process_stripe_event(event: Event, pool: &PgPool) -> Result<String, Error> {
|
||||
info!("stripe event {:?}", event);
|
||||
let connection = pool.get()?;
|
||||
let mut tx = connection.transaction()?;
|
||||
|
||||
match event.data.object {
|
||||
EventObject::CheckoutSession(s) => {
|
||||
let data = match stripe_checkout_data(s) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
error!("{:?}", e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
for item in data.iter() {
|
||||
item.insert(&mut tx)?;
|
||||
item.side_effects(&mut tx)?;
|
||||
}
|
||||
},
|
||||
|
||||
// we only receive the cancelled and updated events
|
||||
// because the checkout object is needed to link
|
||||
// a sub to an account initially and
|
||||
// stripe doesn't guarantee the order
|
||||
// so this just checks if the sub is still active
|
||||
EventObject::Subscription(s) => {
|
||||
let account = subscription_account(&mut tx, s.id.to_string())?;
|
||||
|
||||
let subbed = match s.status {
|
||||
SubscriptionStatus::Active => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
Account::set_subscribed(&mut tx, account, subbed)?;
|
||||
}
|
||||
_ => {
|
||||
error!("unhandled stripe event {:?}", event);
|
||||
return Err(err_msg("UnhanldedEvent"));
|
||||
},
|
||||
};
|
||||
|
||||
tx.commit()?;
|
||||
|
||||
Ok(event.id.to_string())
|
||||
}
|
||||
|
||||
pub fn post_stripe_event(state: web::Data<State>, body: web::Json::<Event>) -> Result<HttpResponse, MnmlHttpError> {
|
||||
let event: Event = body.into_inner();
|
||||
match process_stripe_event(event, &state.pool) {
|
||||
Ok(id)=> {
|
||||
info!("event processed successfully {:?}", id);
|
||||
Ok(HttpResponse::Ok().finish())
|
||||
}
|
||||
Err(e) => {
|
||||
error!("{:?}", e);
|
||||
Err(MnmlHttpError::ServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::prelude::*;
|
||||
use std::fs::File;
|
||||
|
||||
#[test]
|
||||
fn test_stripe_checkout_sku() {
|
||||
let mut f = File::open("./test/checkout.session.completed.purchase.json").expect("couldn't open file");
|
||||
let mut checkout_str = String::new();
|
||||
f.read_to_string(&mut checkout_str)
|
||||
.expect("unable to read file");
|
||||
|
||||
let event: Event = serde_json::from_str(&checkout_str)
|
||||
.expect("could not deserialize");
|
||||
|
||||
let data = match event.data.object {
|
||||
EventObject::CheckoutSession(s) => stripe_checkout_data(s).expect("purchase error"),
|
||||
_ => panic!("unknown event obj"),
|
||||
};
|
||||
|
||||
assert!(data.iter().any(|d| match d {
|
||||
StripeData::Customer { account: _, customer: _, checkout: _ } => true,
|
||||
_ => false,
|
||||
}));
|
||||
|
||||
assert!(data.iter().any(|d| match d {
|
||||
StripeData::Purchase { account: _, amount: _, customer: _, checkout: _ } => true,
|
||||
_ => false,
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stripe_checkout_subscription() {
|
||||
let mut f = File::open("./test/checkout.session.completed.subscription.json").expect("couldn't open file");
|
||||
let mut checkout_str = String::new();
|
||||
f.read_to_string(&mut checkout_str)
|
||||
.expect("unable to read file");
|
||||
|
||||
let event: Event = serde_json::from_str(&checkout_str)
|
||||
.expect("could not deserialize");
|
||||
|
||||
let data = match event.data.object {
|
||||
EventObject::CheckoutSession(s) => stripe_checkout_data(s).expect("subscription error"),
|
||||
_ => panic!("unknown event obj"),
|
||||
};
|
||||
|
||||
assert!(data.iter().any(|d| match d {
|
||||
StripeData::Customer { account: _, customer: _, checkout: _ } => true,
|
||||
_ => false,
|
||||
}));
|
||||
|
||||
assert!(data.iter().any(|d| match d {
|
||||
StripeData::Purchase { account: _, amount: _, customer: _, checkout: _ } => true,
|
||||
_ => false,
|
||||
}));
|
||||
|
||||
assert!(data.iter().any(|d| match d {
|
||||
StripeData::Subscription { account: _, customer: _, checkout: _, subscription: _, } => true,
|
||||
_ => false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
12
server/src/pg.rs
Normal file
12
server/src/pg.rs
Normal file
@ -0,0 +1,12 @@
|
||||
use r2d2::{Pool};
|
||||
use r2d2::{PooledConnection};
|
||||
use r2d2_postgres::{PostgresConnectionManager};
|
||||
|
||||
pub type Db = PooledConnection<PostgresConnectionManager>;
|
||||
pub type PgPool = Pool<PostgresConnectionManager>;
|
||||
|
||||
use postgres::transaction::Transaction;
|
||||
|
||||
pub trait Pg {
|
||||
fn persist(self, &mut Transaction) -> Self;
|
||||
}
|
||||
18
server/src/pubsub.rs
Normal file
18
server/src/pubsub.rs
Normal file
@ -0,0 +1,18 @@
|
||||
// Db Commons
|
||||
use fallible_iterator::{FallibleIterator};
|
||||
use postgres::error::Error;
|
||||
|
||||
use net::{Db};
|
||||
|
||||
pub fn pg_listen(connection: Db) -> Result<(), Error> {
|
||||
connection.execute("LISTEN events;", &[])?;
|
||||
info!("pubsub listening");
|
||||
let notifications = connection.notifications();
|
||||
let mut n_iter = notifications.blocking_iter();
|
||||
loop {
|
||||
let n = n_iter.next()?;
|
||||
if let Some(n) = n {
|
||||
info!("{:?}", n);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,47 +1,36 @@
|
||||
use tungstenite::Message;
|
||||
use tungstenite::protocol::WebSocket;
|
||||
use tungstenite::Message::Binary;
|
||||
use std::time::{Instant};
|
||||
|
||||
use actix_web_actors::ws;
|
||||
|
||||
use postgres::transaction::Transaction;
|
||||
use std::net::{TcpStream};
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use serde_cbor::{from_slice, to_vec};
|
||||
use serde_cbor::{from_slice};
|
||||
use uuid::Uuid;
|
||||
use failure::Error;
|
||||
use failure::err_msg;
|
||||
|
||||
use net::Db;
|
||||
use net::{Db};
|
||||
use ws::{MnmlSocket};
|
||||
use construct::{Construct, construct_spawn, construct_delete};
|
||||
use game::{Game, game_state, game_skill, game_ready};
|
||||
use account::{Account, account_create, account_login, account_from_token, account_constructs, account_instances};
|
||||
use account::{Account, account_constructs, account_instances};
|
||||
use skill::{Skill};
|
||||
use instance::{Instance, instance_state, instance_list, instance_new, instance_ready, instance_join};
|
||||
use vbox::{vbox_accept, vbox_apply, vbox_discard, vbox_combine, vbox_reclaim, vbox_unequip};
|
||||
use item::{Item, ItemInfoCtr, item_info};
|
||||
|
||||
pub struct Rpc;
|
||||
|
||||
impl Rpc {
|
||||
pub fn receive(&self, msg: Message, begin: Instant, db: &Db, client: &mut WebSocket<TcpStream>) -> Result<RpcResponse, Error> {
|
||||
// consume the ws data into bytes
|
||||
let data = msg.into_data();
|
||||
type MnmlWs = ws::WebsocketContext<MnmlSocket>;
|
||||
|
||||
pub fn receive(data: Vec<u8>, db: &Db, _client: &mut MnmlWs, begin: Instant, account: Option<&Account>) -> Result<RpcResult, Error> {
|
||||
// cast the msg to this type to receive method name
|
||||
match from_slice::<RpcMessage>(&data) {
|
||||
Ok(v) => {
|
||||
if v.method == "ping" {
|
||||
return Ok(RpcResponse { method: "pong".to_string(), params: RpcResult::Pong(()) });
|
||||
return Ok(RpcResult::Pong(()));
|
||||
}
|
||||
|
||||
let mut tx = db.transaction()?;
|
||||
|
||||
let account: Option<Account> = match v.token {
|
||||
Some(t) => Some(account_from_token(t, &mut tx)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let account_name = match &account {
|
||||
let account_name = match account {
|
||||
Some(a) => a.name.clone(),
|
||||
None => "none".to_string(),
|
||||
};
|
||||
@ -49,46 +38,41 @@ impl Rpc {
|
||||
// check the method
|
||||
// if no auth required
|
||||
match v.method.as_ref() {
|
||||
"account_create" => (),
|
||||
"account_login" => (),
|
||||
"item_info" => (),
|
||||
"item_info" => return Ok(RpcResult::ItemInfo(item_info())),
|
||||
_ => match account {
|
||||
Some(_) => (),
|
||||
None => return Err(err_msg("auth required")),
|
||||
},
|
||||
};
|
||||
|
||||
let account = account.unwrap();
|
||||
|
||||
// now we have the method name
|
||||
// match on that to determine what fn to call
|
||||
let response = match v.method.as_ref() {
|
||||
// NO AUTH
|
||||
"account_create" => Rpc::account_create(data, &mut tx, client),
|
||||
"account_login" => Rpc::account_login(data, &mut tx, client),
|
||||
"item_info" => Ok(RpcResponse { method: "item_info".to_string(), params: RpcResult::ItemInfo(item_info()) }),
|
||||
"account_state" => return Ok(RpcResult::AccountState(account.clone())),
|
||||
"account_constructs" => handle_account_constructs(data, &mut tx, account),
|
||||
"account_instances" => handle_account_instances(data, &mut tx, account),
|
||||
|
||||
// AUTH METHODS
|
||||
"account_constructs" => Rpc::account_constructs(data, &mut tx, account.unwrap(), client),
|
||||
"account_instances" => Rpc::account_instances(data, &mut tx, account.unwrap(), client),
|
||||
"construct_spawn" => handle_construct_spawn(data, &mut tx, account),
|
||||
"construct_delete" => handle_construct_delete(data, &mut tx, account),
|
||||
|
||||
"construct_spawn" => Rpc::construct_spawn(data, &mut tx, account.unwrap(), client),
|
||||
"construct_delete" => Rpc::construct_delete(data, &mut tx, account.unwrap(), client),
|
||||
"game_state" => handle_game_state(data, &mut tx, account),
|
||||
"game_skill" => handle_game_skill(data, &mut tx, account),
|
||||
"game_ready" => handle_game_ready(data, &mut tx, account),
|
||||
|
||||
"game_state" => Rpc::game_state(data, &mut tx, account.unwrap(), client),
|
||||
"game_skill" => Rpc::game_skill(data, &mut tx, account.unwrap(), client),
|
||||
"game_ready" => Rpc::game_ready(data, &mut tx, account.unwrap(), client),
|
||||
"instance_list" => handle_instance_list(data, &mut tx, account),
|
||||
"instance_join" => handle_instance_join(data, &mut tx, account),
|
||||
"instance_ready" => handle_instance_ready(data, &mut tx, account),
|
||||
"instance_new" => handle_instance_new(data, &mut tx, account),
|
||||
"instance_state" => handle_instance_state(data, &mut tx, account),
|
||||
|
||||
"instance_list" => Rpc::instance_list(data, &mut tx, account.unwrap(), client),
|
||||
"instance_join" => Rpc::instance_join(data, &mut tx, account.unwrap(), client),
|
||||
"instance_ready" => Rpc::instance_ready(data, &mut tx, account.unwrap(), client),
|
||||
"instance_new" => Rpc::instance_new(data, &mut tx, account.unwrap(), client),
|
||||
"instance_state" => Rpc::instance_state(data, &mut tx, account.unwrap(), client),
|
||||
|
||||
"vbox_accept" => Rpc::vbox_accept(data, &mut tx, account.unwrap(), client),
|
||||
"vbox_apply" => Rpc::vbox_apply(data, &mut tx, account.unwrap(), client),
|
||||
"vbox_combine" => Rpc::vbox_combine(data, &mut tx, account.unwrap(), client),
|
||||
"vbox_discard" => Rpc::vbox_discard(data, &mut tx, account.unwrap(), client),
|
||||
"vbox_reclaim" => Rpc::vbox_reclaim(data, &mut tx, account.unwrap(), client),
|
||||
"vbox_unequip" => Rpc::vbox_unequip(data, &mut tx, account.unwrap(), client),
|
||||
"vbox_accept" => handle_vbox_accept(data, &mut tx, account),
|
||||
"vbox_apply" => handle_vbox_apply(data, &mut tx, account),
|
||||
"vbox_combine" => handle_vbox_combine(data, &mut tx, account),
|
||||
"vbox_discard" => handle_vbox_discard(data, &mut tx, account),
|
||||
"vbox_reclaim" => handle_vbox_reclaim(data, &mut tx, account),
|
||||
"vbox_unequip" => handle_vbox_unequip(data, &mut tx, account),
|
||||
|
||||
_ => Err(format_err!("unknown method - {:?}", v.method)),
|
||||
};
|
||||
@ -104,306 +88,141 @@ impl Rpc {
|
||||
Err(err_msg("unknown error"))
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn send_msg(client: &mut WebSocket<TcpStream>, msg: RpcResponse) -> Result<(), Error> {
|
||||
let bytes = to_vec(&msg)?;
|
||||
match client.write_message(Binary(bytes)) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => Err(err_msg(e))
|
||||
}
|
||||
}
|
||||
|
||||
fn game_state(data: Vec<u8>, tx: &mut Transaction, account: Account, _client: &mut WebSocket<TcpStream>) -> Result<RpcResponse, Error> {
|
||||
fn handle_game_state(data: Vec<u8>, tx: &mut Transaction, account: &Account) -> Result<RpcResult, Error> {
|
||||
let msg = from_slice::<GameStateMsg>(&data).or(Err(err_msg("invalid params")))?;
|
||||
return Ok(RpcResult::GameState(game_state(msg.params, tx, &account)?));
|
||||
}
|
||||
|
||||
let game_response = RpcResponse {
|
||||
method: "game_state".to_string(),
|
||||
params: RpcResult::GameState(game_state(msg.params, tx, &account)?)
|
||||
};
|
||||
// fn handle_game_pve(data: Vec<u8>, tx: &mut Transaction, account: &Account) -> Result<RpcResult, Error> {
|
||||
// let msg = from_slice::<GamePveMsg>(&data).or(Err(err_msg("invalid params")))?;
|
||||
|
||||
return Ok(game_response);
|
||||
}
|
||||
// let game_response = RpcResponse {
|
||||
// method: "game_state".to_string(),
|
||||
// params: RpcResult::GameState(game_pve(msg.params, tx, &account)?)
|
||||
// };
|
||||
|
||||
// fn game_pve(data: Vec<u8>, tx: &mut Transaction, account: Account, _client: &mut WebSocket<TcpStream>) -> Result<RpcResponse, Error> {
|
||||
// let msg = from_slice::<GamePveMsg>(&data).or(Err(err_msg("invalid params")))?;
|
||||
// return Ok(game_response);
|
||||
// }
|
||||
|
||||
// let game_response = RpcResponse {
|
||||
// method: "game_state".to_string(),
|
||||
// params: RpcResult::GameState(game_pve(msg.params, tx, &account)?)
|
||||
// };
|
||||
|
||||
// return Ok(game_response);
|
||||
// }
|
||||
|
||||
fn game_skill(data: Vec<u8>, tx: &mut Transaction, account: Account, _client: &mut WebSocket<TcpStream>) -> Result<RpcResponse, Error> {
|
||||
fn handle_game_skill(data: Vec<u8>, tx: &mut Transaction, account: &Account) -> Result<RpcResult, Error> {
|
||||
let msg = from_slice::<GameSkillMsg>(&data).or(Err(err_msg("invalid params")))?;
|
||||
Ok(RpcResult::GameState(game_skill(msg.params, tx, &account)?))
|
||||
}
|
||||
|
||||
let game_response = RpcResponse {
|
||||
method: "game_state".to_string(),
|
||||
params: RpcResult::GameState(game_skill(msg.params, tx, &account)?)
|
||||
};
|
||||
|
||||
return Ok(game_response);
|
||||
}
|
||||
|
||||
fn game_ready(data: Vec<u8>, tx: &mut Transaction, account: Account, _client: &mut WebSocket<TcpStream>) -> Result<RpcResponse, Error> {
|
||||
fn handle_game_ready(data: Vec<u8>, tx: &mut Transaction, account: &Account) -> Result<RpcResult, Error> {
|
||||
let msg = from_slice::<GameStateMsg>(&data).or(Err(err_msg("invalid params")))?;
|
||||
|
||||
let game_response = RpcResponse {
|
||||
method: "game_state".to_string(),
|
||||
params: RpcResult::GameState(game_ready(msg.params, tx, &account)?)
|
||||
};
|
||||
|
||||
return Ok(game_response);
|
||||
}
|
||||
Ok(RpcResult::GameState(game_ready(msg.params, tx, &account)?))
|
||||
}
|
||||
|
||||
|
||||
fn construct_spawn(data: Vec<u8>, tx: &mut Transaction, account: Account, client: &mut WebSocket<TcpStream>) -> Result<RpcResponse, Error> {
|
||||
fn handle_construct_spawn(data: Vec<u8>, tx: &mut Transaction, account: &Account) -> Result<RpcResult, Error> {
|
||||
let msg = from_slice::<ConstructSpawnMsg>(&data).or(Err(err_msg("invalid params")))?;
|
||||
construct_spawn(msg.params, tx, &account)?;
|
||||
Ok(RpcResult::AccountConstructs(account_constructs(tx, &account)?))
|
||||
}
|
||||
|
||||
Rpc::send_msg(client, RpcResponse {
|
||||
method: "construct_spawn".to_string(),
|
||||
params: RpcResult::ConstructSpawn(construct_spawn(msg.params, tx, &account)?)
|
||||
})?;
|
||||
|
||||
let construct_list = RpcResponse {
|
||||
method: "account_constructs".to_string(),
|
||||
params: RpcResult::ConstructList(account_constructs(tx, &account)?)
|
||||
};
|
||||
|
||||
Ok(construct_list)
|
||||
}
|
||||
|
||||
fn construct_delete(data: Vec<u8>, tx: &mut Transaction, account: Account, _client: &mut WebSocket<TcpStream>) -> Result<RpcResponse, Error> {
|
||||
fn handle_construct_delete(data: Vec<u8>, tx: &mut Transaction, account: &Account) -> Result<RpcResult, Error> {
|
||||
let msg = from_slice::<ConstructDeleteMsg>(&data).or(Err(err_msg("invalid params")))?;
|
||||
|
||||
construct_delete(tx, msg.params.id, account.id)?;
|
||||
|
||||
let construct_list = RpcResponse {
|
||||
method: "account_constructs".to_string(),
|
||||
params: RpcResult::ConstructList(account_constructs(tx, &account)?)
|
||||
};
|
||||
|
||||
Ok(construct_list)
|
||||
}
|
||||
Ok(RpcResult::AccountConstructs(account_constructs(tx, &account)?))
|
||||
}
|
||||
|
||||
|
||||
fn account_create(data: Vec<u8>, tx: &mut Transaction, _client: &mut WebSocket<TcpStream>) -> Result<RpcResponse, Error> {
|
||||
let msg = from_slice::<AccountCreateMsg>(&data).or(Err(err_msg("invalid params")))?;
|
||||
// fn handle_account_create(data: Vec<u8>, tx: &mut Transaction) -> Result<RpcResult, Error> {
|
||||
// let msg = from_slice::<AccountCreateMsg>(&data).or(Err(err_msg("invalid params")))?;
|
||||
// let account = account_create(msg.params, tx)?;
|
||||
// Ok(RpcResult::Account(account))
|
||||
// }
|
||||
|
||||
let account = account_create(msg.params, tx)?;
|
||||
// fn handle_account_login(data: Vec<u8>, tx: &mut Transaction) -> Result<RpcResult, Error> {
|
||||
// let msg = from_slice::<AccountLoginMsg>(&data).or(Err(err_msg("invalid params")))?;
|
||||
// Ok(RpcResult::Account(account_login(msg.params, tx)?))
|
||||
// }
|
||||
|
||||
Ok(RpcResponse {
|
||||
method: "account_create".to_string(),
|
||||
params: RpcResult::Account(account)
|
||||
})
|
||||
}
|
||||
fn handle_account_constructs(_data: Vec<u8>, tx: &mut Transaction, account: &Account) -> Result<RpcResult, Error> {
|
||||
Ok(RpcResult::AccountConstructs(account_constructs(tx, &account)?))
|
||||
}
|
||||
|
||||
fn account_login(data: Vec<u8>, tx: &mut Transaction, _client: &mut WebSocket<TcpStream>) -> Result<RpcResponse, Error> {
|
||||
match from_slice::<AccountLoginMsg>(&data) {
|
||||
Ok(v) => Ok(RpcResponse {
|
||||
method: v.method,
|
||||
params: RpcResult::Account(account_login(v.params, tx)?)
|
||||
}),
|
||||
Err(_e) => Err(err_msg("invalid params")),
|
||||
}
|
||||
}
|
||||
fn handle_account_instances(_data: Vec<u8>, tx: &mut Transaction, account: &Account) -> Result<RpcResult, Error> {
|
||||
Ok(RpcResult::AccountInstances(account_instances(tx, &account)?))
|
||||
}
|
||||
|
||||
// fn account_demo(_data: Vec<u8>, tx: &mut Transaction, _client: &mut WebSocket<TcpStream>) -> Result<RpcResponse, Error> {
|
||||
// let mut rng = thread_rng();
|
||||
|
||||
// let acc_name: String = iter::repeat(()).map(|()| rng.sample(Alphanumeric)).take(8).collect();
|
||||
|
||||
// let account = account_create(AccountCreateParams { name: acc_name, password: "grepgrepgrep".to_string() }, tx)?;
|
||||
|
||||
// let name: String = iter::repeat(()).map(|()| rng.sample(Alphanumeric)).take(8).collect();
|
||||
// construct_spawn(ConstructSpawnParams { name }, tx, &account)?;
|
||||
|
||||
// let name: String = iter::repeat(()).map(|()| rng.sample(Alphanumeric)).take(8).collect();
|
||||
// construct_spawn(ConstructSpawnParams { name }, tx, &account)?;
|
||||
|
||||
// let name: String = iter::repeat(()).map(|()| rng.sample(Alphanumeric)).take(8).collect();
|
||||
// construct_spawn(ConstructSpawnParams { name }, tx, &account)?;
|
||||
|
||||
// let res = RpcResponse {
|
||||
// method: "account_create".to_string(),
|
||||
// params: RpcResult::Account(account),
|
||||
// };
|
||||
|
||||
// return Ok(res);
|
||||
// }
|
||||
|
||||
|
||||
fn account_constructs(_data: Vec<u8>, tx: &mut Transaction, account: Account, _client: &mut WebSocket<TcpStream>) -> Result<RpcResponse, Error> {
|
||||
Ok(RpcResponse {
|
||||
method: "account_constructs".to_string(),
|
||||
params: RpcResult::ConstructList(account_constructs(tx, &account)?)
|
||||
})
|
||||
}
|
||||
|
||||
fn account_instances(_data: Vec<u8>, tx: &mut Transaction, account: Account, _client: &mut WebSocket<TcpStream>) -> Result<RpcResponse, Error> {
|
||||
Ok(RpcResponse {
|
||||
method: "account_instances".to_string(),
|
||||
params: RpcResult::InstanceList(account_instances(tx, &account)?)
|
||||
})
|
||||
}
|
||||
|
||||
fn instance_new(data: Vec<u8>, tx: &mut Transaction, account: Account, _client: &mut WebSocket<TcpStream>) -> Result<RpcResponse, Error> {
|
||||
fn handle_instance_new(data: Vec<u8>, tx: &mut Transaction, account: &Account) -> Result<RpcResult, Error> {
|
||||
let msg = from_slice::<InstanceLobbyMsg>(&data).or(Err(err_msg("invalid params")))?;
|
||||
Ok(RpcResult::InstanceState(instance_new(msg.params, tx, &account)?))
|
||||
}
|
||||
|
||||
let response = RpcResponse {
|
||||
method: "instance_state".to_string(),
|
||||
params: RpcResult::InstanceState(instance_new(msg.params, tx, &account)?)
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
fn instance_ready(data: Vec<u8>, tx: &mut Transaction, account: Account, _client: &mut WebSocket<TcpStream>) -> Result<RpcResponse, Error> {
|
||||
fn handle_instance_ready(data: Vec<u8>, tx: &mut Transaction, account: &Account) -> Result<RpcResult, Error> {
|
||||
let msg = from_slice::<InstanceReadyMsg>(&data).or(Err(err_msg("invalid params")))?;
|
||||
Ok(RpcResult::InstanceState(instance_ready(msg.params, tx, &account)?))
|
||||
}
|
||||
|
||||
let response = RpcResponse {
|
||||
method: "instance_state".to_string(),
|
||||
params: RpcResult::InstanceState(instance_ready(msg.params, tx, &account)?)
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
fn instance_join(data: Vec<u8>, tx: &mut Transaction, account: Account, _client: &mut WebSocket<TcpStream>) -> Result<RpcResponse, Error> {
|
||||
fn handle_instance_join(data: Vec<u8>, tx: &mut Transaction, account: &Account) -> Result<RpcResult, Error> {
|
||||
let msg = from_slice::<InstanceJoinMsg>(&data).or(Err(err_msg("invalid params")))?;
|
||||
Ok(RpcResult::InstanceState(instance_join(msg.params, tx, &account)?))
|
||||
}
|
||||
|
||||
let response = RpcResponse {
|
||||
method: "instance_state".to_string(),
|
||||
params: RpcResult::InstanceState(instance_join(msg.params, tx, &account)?)
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
fn instance_list(_data: Vec<u8>, tx: &mut Transaction, _account: Account, _client: &mut WebSocket<TcpStream>) -> Result<RpcResponse, Error> {
|
||||
let response = RpcResponse {
|
||||
method: "instance_list".to_string(),
|
||||
params: RpcResult::InstanceList(instance_list(tx)?)
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
fn handle_instance_list(_data: Vec<u8>, tx: &mut Transaction, _account: &Account) -> Result<RpcResult, Error> {
|
||||
Ok(RpcResult::OpenInstances(instance_list(tx)?))
|
||||
}
|
||||
|
||||
|
||||
// fn instance_ready(data: Vec<u8>, tx: &mut Transaction, account: Account, _client: &mut WebSocket<TcpStream>) -> Result<RpcResponse, Error> {
|
||||
// let msg = from_slice::<InstanceReadyMsg>(&data).or(Err(err_msg("invalid params")))?;
|
||||
|
||||
// let response = RpcResponse {
|
||||
// method: "game_state".to_string(),
|
||||
// params: RpcResult::GameState(instance_ready(msg.params, tx, &account)?)
|
||||
// };
|
||||
|
||||
// return Ok(response);
|
||||
// }
|
||||
|
||||
fn instance_state(data: Vec<u8>, tx: &mut Transaction, account: Account, _client: &mut WebSocket<TcpStream>) -> Result<RpcResponse, Error> {
|
||||
fn handle_instance_state(data: Vec<u8>, tx: &mut Transaction, account: &Account) -> Result<RpcResult, Error> {
|
||||
let msg = from_slice::<InstanceStateMsg>(&data).or(Err(err_msg("invalid params")))?;
|
||||
match instance_state(msg.params, tx, &account)? {
|
||||
RpcResult::GameState(p) => Ok(RpcResponse {
|
||||
method: "game_state".to_string(),
|
||||
params: RpcResult::GameState(p),
|
||||
}),
|
||||
RpcResult::InstanceState(p) => Ok(RpcResponse {
|
||||
method: "instance_state".to_string(),
|
||||
params: RpcResult::InstanceState(p),
|
||||
}),
|
||||
RpcResult::GameState(p) => Ok(RpcResult::GameState(p)),
|
||||
RpcResult::InstanceState(p) => Ok(RpcResult::InstanceState(p)),
|
||||
_ => Err(err_msg("unhandled instance state"))
|
||||
}
|
||||
}
|
||||
|
||||
fn vbox_accept(data: Vec<u8>, tx: &mut Transaction, account: Account, _client: &mut WebSocket<TcpStream>) -> Result<RpcResponse, Error> {
|
||||
let msg = from_slice::<VboxAcceptMsg>(&data).or(Err(err_msg("invalid params")))?;
|
||||
|
||||
let response = RpcResponse {
|
||||
method: "instance_state".to_string(),
|
||||
params: RpcResult::InstanceState(vbox_accept(msg.params, tx, &account)?)
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
fn vbox_discard(data: Vec<u8>, tx: &mut Transaction, account: Account, _client: &mut WebSocket<TcpStream>) -> Result<RpcResponse, Error> {
|
||||
let msg = from_slice::<VboxDiscardMsg>(&data).or(Err(err_msg("invalid params")))?;
|
||||
|
||||
let response = RpcResponse {
|
||||
method: "instance_state".to_string(),
|
||||
params: RpcResult::InstanceState(vbox_discard(msg.params, tx, &account)?)
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
|
||||
fn vbox_combine(data: Vec<u8>, tx: &mut Transaction, account: Account, _client: &mut WebSocket<TcpStream>) -> Result<RpcResponse, Error> {
|
||||
let msg = from_slice::<VboxCombineMsg>(&data).or(Err(err_msg("invalid params")))?;
|
||||
|
||||
let response = RpcResponse {
|
||||
method: "instance_state".to_string(),
|
||||
params: RpcResult::InstanceState(vbox_combine(msg.params, tx, &account)?)
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
fn vbox_apply(data: Vec<u8>, tx: &mut Transaction, account: Account, _client: &mut WebSocket<TcpStream>) -> Result<RpcResponse, Error> {
|
||||
let msg = from_slice::<VboxApplyMsg>(&data).or(Err(err_msg("invalid params")))?;
|
||||
|
||||
let response = RpcResponse {
|
||||
method: "instance_state".to_string(),
|
||||
params: RpcResult::InstanceState(vbox_apply(msg.params, tx, &account)?)
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
fn vbox_reclaim(data: Vec<u8>, tx: &mut Transaction, account: Account, _client: &mut WebSocket<TcpStream>) -> Result<RpcResponse, Error> {
|
||||
let msg = from_slice::<VboxReclaimMsg>(&data).or(Err(err_msg("invalid params")))?;
|
||||
|
||||
let response = RpcResponse {
|
||||
method: "instance_state".to_string(),
|
||||
params: RpcResult::InstanceState(vbox_reclaim(msg.params, tx, &account)?)
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
fn vbox_unequip(data: Vec<u8>, tx: &mut Transaction, account: Account, _client: &mut WebSocket<TcpStream>) -> Result<RpcResponse, Error> {
|
||||
let msg = from_slice::<VboxUnequipMsg>(&data).or(Err(err_msg("invalid params")))?;
|
||||
|
||||
let response = RpcResponse {
|
||||
method: "instance_state".to_string(),
|
||||
params: RpcResult::InstanceState(vbox_unequip(msg.params, tx, &account)?)
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_vbox_accept(data: Vec<u8>, tx: &mut Transaction, account: &Account) -> Result<RpcResult, Error> {
|
||||
let msg = from_slice::<VboxAcceptMsg>(&data).or(Err(err_msg("invalid params")))?;
|
||||
Ok(RpcResult::InstanceState(vbox_accept(msg.params, tx, &account)?))
|
||||
}
|
||||
|
||||
fn handle_vbox_discard(data: Vec<u8>, tx: &mut Transaction, account: &Account) -> Result<RpcResult, Error> {
|
||||
let msg = from_slice::<VboxDiscardMsg>(&data).or(Err(err_msg("invalid params")))?;
|
||||
Ok(RpcResult::InstanceState(vbox_discard(msg.params, tx, &account)?))
|
||||
}
|
||||
|
||||
|
||||
fn handle_vbox_combine(data: Vec<u8>, tx: &mut Transaction, account: &Account) -> Result<RpcResult, Error> {
|
||||
let msg = from_slice::<VboxCombineMsg>(&data).or(Err(err_msg("invalid params")))?;
|
||||
Ok(RpcResult::InstanceState(vbox_combine(msg.params, tx, &account)?))
|
||||
}
|
||||
|
||||
fn handle_vbox_apply(data: Vec<u8>, tx: &mut Transaction, account: &Account) -> Result<RpcResult, Error> {
|
||||
let msg = from_slice::<VboxApplyMsg>(&data).or(Err(err_msg("invalid params")))?;
|
||||
Ok(RpcResult::InstanceState(vbox_apply(msg.params, tx, &account)?))
|
||||
}
|
||||
|
||||
fn handle_vbox_reclaim(data: Vec<u8>, tx: &mut Transaction, account: &Account) -> Result<RpcResult, Error> {
|
||||
let msg = from_slice::<VboxReclaimMsg>(&data).or(Err(err_msg("invalid params")))?;
|
||||
Ok(RpcResult::InstanceState(vbox_reclaim(msg.params, tx, &account)?))
|
||||
}
|
||||
|
||||
fn handle_vbox_unequip(data: Vec<u8>, tx: &mut Transaction, account: &Account) -> Result<RpcResult, Error> {
|
||||
let msg = from_slice::<VboxUnequipMsg>(&data).or(Err(err_msg("invalid params")))?;
|
||||
Ok(RpcResult::InstanceState(vbox_unequip(msg.params, tx, &account)?))
|
||||
}
|
||||
|
||||
#[derive(Debug,Clone,Serialize,Deserialize)]
|
||||
pub struct RpcResponse {
|
||||
pub method: String,
|
||||
params: RpcResult,
|
||||
pub struct RpcErrorResponse {
|
||||
pub err: String
|
||||
}
|
||||
|
||||
#[derive(Debug,Clone,Serialize,Deserialize)]
|
||||
pub enum RpcResult {
|
||||
ConstructSpawn(Construct),
|
||||
ConstructForget(Construct),
|
||||
ConstructLearn(Construct),
|
||||
ConstructUnspec(Construct),
|
||||
Account(Account),
|
||||
ConstructList(Vec<Construct>),
|
||||
AccountState(Account),
|
||||
AccountConstructs(Vec<Construct>),
|
||||
AccountInstances(Vec<Instance>),
|
||||
GameState(Game),
|
||||
ItemInfo(ItemInfoCtr),
|
||||
|
||||
InstanceList(Vec<Instance>),
|
||||
OpenInstances(Vec<Instance>),
|
||||
InstanceState(Instance),
|
||||
|
||||
Pong(()),
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
// Db Commons
|
||||
use postgres::transaction::Transaction;
|
||||
use failure::Error;
|
||||
|
||||
132
server/src/ws.rs
Normal file
132
server/src/ws.rs
Normal file
@ -0,0 +1,132 @@
|
||||
use std::time::{Instant, Duration};
|
||||
|
||||
use actix_web::{web, HttpMessage, HttpRequest, HttpResponse};
|
||||
use actix_web_actors::ws;
|
||||
use actix::prelude::*;
|
||||
|
||||
use account::{Account};
|
||||
use serde_cbor::{to_vec};
|
||||
use net::{PgPool, State, MnmlHttpError};
|
||||
|
||||
use rpc::{receive, RpcResult, RpcErrorResponse};
|
||||
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
|
||||
const CLIENT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
pub struct MnmlSocket {
|
||||
hb: Instant,
|
||||
pool: PgPool,
|
||||
account: Option<Account>,
|
||||
}
|
||||
|
||||
impl Actor for MnmlSocket {
|
||||
type Context = ws::WebsocketContext<Self>;
|
||||
|
||||
// once the actor has been started this fn runs
|
||||
// it starts the heartbeat interval and keepalive
|
||||
fn started(&mut self, ctx: &mut Self::Context) {
|
||||
self.hb(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for `ws::Message`
|
||||
impl StreamHandler<ws::Message, ws::ProtocolError> for MnmlSocket {
|
||||
fn started(&mut self, ctx: &mut Self::Context) {
|
||||
match self.account.as_ref() {
|
||||
Some(a) => {
|
||||
info!("user connected {:?}", a);
|
||||
let account_state = to_vec(&RpcResult::AccountState(a.clone()))
|
||||
.expect("could not serialize account state");
|
||||
ctx.binary(account_state)
|
||||
},
|
||||
None => info!("new connection"),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(&mut self, msg: ws::Message, ctx: &mut Self::Context) {
|
||||
// process websocket messages
|
||||
let begin = Instant::now();
|
||||
debug!("msg: {:?}", msg);
|
||||
match msg {
|
||||
ws::Message::Ping(msg) => {
|
||||
self.hb = Instant::now();
|
||||
ctx.pong(&msg);
|
||||
}
|
||||
ws::Message::Pong(_) => {
|
||||
self.hb = Instant::now();
|
||||
}
|
||||
ws::Message::Text(_text) => (),
|
||||
ws::Message::Close(_) => {
|
||||
match self.account.as_ref() {
|
||||
Some(a) => info!("disconnected {:?}", a),
|
||||
None => info!("disconnected"),
|
||||
}
|
||||
ctx.stop();
|
||||
}
|
||||
ws::Message::Nop => (),
|
||||
ws::Message::Binary(bin) => {
|
||||
let db_connection = self.pool.get().expect("unable to get db connection");
|
||||
match receive(bin.to_vec(), &db_connection, ctx, begin, self.account.as_ref()) {
|
||||
Ok(reply) => {
|
||||
let response = to_vec(&reply)
|
||||
.expect("failed to serialize response");
|
||||
ctx.binary(response);
|
||||
},
|
||||
Err(e) => {
|
||||
let response = to_vec(&RpcErrorResponse { err: e.to_string() })
|
||||
.expect("failed to serialize error response");
|
||||
ctx.binary(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MnmlSocket {
|
||||
fn new(state: web::Data<State>, account: Option<Account>) -> MnmlSocket {
|
||||
// idk why this has to be cloned again
|
||||
// i guess because each socket is added as a new thread?
|
||||
MnmlSocket { hb: Instant::now(), pool: state.pool.clone(), account }
|
||||
}
|
||||
|
||||
// starts the keepalive interval once actor started
|
||||
fn hb(&self, ctx: &mut <MnmlSocket as Actor>::Context) {
|
||||
ctx.run_interval(HEARTBEAT_INTERVAL, |act, ctx| {
|
||||
if Instant::now().duration_since(act.hb) > CLIENT_TIMEOUT {
|
||||
info!("idle connection terminated");
|
||||
|
||||
// stop actor
|
||||
ctx.stop();
|
||||
|
||||
// don't try to send a ping
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ping("");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// idk how this stuff works
|
||||
// but the args extract what you need from the incoming requests
|
||||
// this grabs
|
||||
// the req obj itself which we need for cookies
|
||||
// the application state
|
||||
// and the websocket stream
|
||||
pub fn connect(r: HttpRequest, state: web::Data<State>, stream: web::Payload) -> Result<HttpResponse, MnmlHttpError> {
|
||||
let account: Option<Account> = match r.cookie("x-auth-token") {
|
||||
Some(t) => {
|
||||
let db = state.pool.get().or(Err(MnmlHttpError::ServerError))?;
|
||||
let mut tx = db.transaction().or(Err(MnmlHttpError::ServerError))?;
|
||||
match Account::from_token(&mut tx, t.value().to_string()) {
|
||||
Ok(a) => Some(a),
|
||||
Err(_) => None,
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
// state.pubsub.try_send(WsEvent(account.clone())).or(Err(MnmlHttpError::ServerError))?;
|
||||
ws::start(MnmlSocket::new(state, account), &r, stream).or(Err(MnmlHttpError::ServerError))
|
||||
}
|
||||
64
server/test/checkout.session.completed.purchase.json
Normal file
64
server/test/checkout.session.completed.purchase.json
Normal file
@ -0,0 +1,64 @@
|
||||
{
|
||||
"id": "evt_1Emw4mGf8y65MteUNt6J2zlN",
|
||||
"object": "event",
|
||||
"api_version": "2019-05-16",
|
||||
"created": 1560921076,
|
||||
"data": {
|
||||
"object": {
|
||||
"id": "cs_test_0FldEAQpyiya1xFLtZpPuEaWQ8OGnMMsotlnwrWCk8jXrAeeMtVruHwB",
|
||||
"object": "checkout.session",
|
||||
"billing_address_collection": null,
|
||||
"cancel_url": "http://localhost:40080/payments/cancel",
|
||||
"client_reference_id": "ff3bbecb-e744-4674-b411-a11d6832a5ac",
|
||||
"customer": "cus_FHV47hm01bNBpG",
|
||||
"customer_email": null,
|
||||
"display_items": [
|
||||
{
|
||||
"amount": 500,
|
||||
"currency": "aud",
|
||||
"quantity": 1,
|
||||
"sku": {
|
||||
"id": "sku_FHUfNEhWQaVDaT",
|
||||
"object": "sku",
|
||||
"active": true,
|
||||
"attributes": {
|
||||
"name": "20 bits"
|
||||
},
|
||||
"created": 1560919564,
|
||||
"currency": "aud",
|
||||
"image": null,
|
||||
"inventory": {
|
||||
"quantity": null,
|
||||
"type": "infinite",
|
||||
"value": null
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
},
|
||||
"package_dimensions": null,
|
||||
"price": 500,
|
||||
"product": "prod_FHUfY9DFwl0pPl",
|
||||
"updated": 1560919796
|
||||
},
|
||||
"type": "sku"
|
||||
}
|
||||
],
|
||||
"livemode": false,
|
||||
"locale": null,
|
||||
"payment_intent": "pi_1Emw4WGf8y65MteUBrVOy4ME",
|
||||
"payment_method_types": [
|
||||
"card"
|
||||
],
|
||||
"submit_type": null,
|
||||
"subscription": null,
|
||||
"success_url": "http://localhost:40080/payments/success"
|
||||
}
|
||||
},
|
||||
"livemode": false,
|
||||
"pending_webhooks": 3,
|
||||
"request": {
|
||||
"id": null,
|
||||
"idempotency_key": null
|
||||
},
|
||||
"type": "checkout.session.completed"
|
||||
}
|
||||
63
server/test/checkout.session.completed.subscription.json
Normal file
63
server/test/checkout.session.completed.subscription.json
Normal file
@ -0,0 +1,63 @@
|
||||
{
|
||||
"id": "evt_1EmH4cGf8y65MteUj9qhzJzD",
|
||||
"object": "event",
|
||||
"api_version": "2019-05-16",
|
||||
"created": 1560763462,
|
||||
"data": {
|
||||
"object": {
|
||||
"id": "cs_test_RH4RVfajVXRAcruFssXLthaDnfVBJGiPumUfMzPdj5DixpieRl645hkQ",
|
||||
"object": "checkout.session",
|
||||
"billing_address_collection": null,
|
||||
"cancel_url": "http://localhost:40080/payments/cancel",
|
||||
"client_reference_id": "ff3bbecb-e744-4674-b411-a11d6832a5ac",
|
||||
"customer": "cus_FGoioD9GGtTXlW",
|
||||
"customer_email": null,
|
||||
"display_items": [
|
||||
{
|
||||
"amount": 1000,
|
||||
"currency": "aud",
|
||||
"plan": {
|
||||
"id": "plan_FGmRwawcOJJ7Nv",
|
||||
"object": "plan",
|
||||
"active": true,
|
||||
"aggregate_usage": null,
|
||||
"amount": 1000,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1560755040,
|
||||
"currency": "aud",
|
||||
"interval": "month",
|
||||
"interval_count": 1,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
},
|
||||
"nickname": "basic",
|
||||
"product": "prod_FGmRFYmB700pM5",
|
||||
"tiers": null,
|
||||
"tiers_mode": null,
|
||||
"transform_usage": null,
|
||||
"trial_period_days": null,
|
||||
"usage_type": "licensed"
|
||||
},
|
||||
"quantity": 1,
|
||||
"type": "plan"
|
||||
}
|
||||
],
|
||||
"livemode": false,
|
||||
"locale": null,
|
||||
"payment_intent": null,
|
||||
"payment_method_types": [
|
||||
"card"
|
||||
],
|
||||
"submit_type": null,
|
||||
"subscription": "sub_FGoiRaWHZUF01V",
|
||||
"success_url": "http://localhost:40080/payments/success"
|
||||
}
|
||||
},
|
||||
"livemode": false,
|
||||
"pending_webhooks": 1,
|
||||
"request": {
|
||||
"id": null,
|
||||
"idempotency_key": null
|
||||
},
|
||||
"type": "checkout.session.completed"
|
||||
}
|
||||
43
server/test/checkout.session.completed.test.json
Normal file
43
server/test/checkout.session.completed.test.json
Normal file
@ -0,0 +1,43 @@
|
||||
{
|
||||
"created": 1326853478,
|
||||
"livemode": false,
|
||||
"id": "evt_00000000000000",
|
||||
"type": "checkout.session.completed",
|
||||
"object": "event",
|
||||
"request": null,
|
||||
"pending_webhooks": 1,
|
||||
"api_version": "2019-05-16",
|
||||
"data": {
|
||||
"object": {
|
||||
"id": "cs_00000000000000",
|
||||
"object": "checkout.session",
|
||||
"billing_address_collection": null,
|
||||
"cancel_url": "https://example.com/cancel",
|
||||
"client_reference_id": null,
|
||||
"customer": null,
|
||||
"customer_email": null,
|
||||
"display_items": [
|
||||
{
|
||||
"amount": 1500,
|
||||
"currency": "usd",
|
||||
"custom": {
|
||||
"description": "Comfortable cotton t-shirt",
|
||||
"images": null,
|
||||
"name": "T-shirt"
|
||||
},
|
||||
"quantity": 2,
|
||||
"type": "custom"
|
||||
}
|
||||
],
|
||||
"livemode": false,
|
||||
"locale": null,
|
||||
"payment_intent": "pi_00000000000000",
|
||||
"payment_method_types": [
|
||||
"card"
|
||||
],
|
||||
"submit_type": null,
|
||||
"subscription": null,
|
||||
"success_url": "https://example.com/success"
|
||||
}
|
||||
}
|
||||
}
|
||||
109
server/test/customer.subscription.created.json
Normal file
109
server/test/customer.subscription.created.json
Normal file
@ -0,0 +1,109 @@
|
||||
{
|
||||
"created": 1326853478,
|
||||
"livemode": false,
|
||||
"id": "evt_00000000000000",
|
||||
"type": "customer.subscription.created",
|
||||
"object": "event",
|
||||
"request": null,
|
||||
"pending_webhooks": 1,
|
||||
"api_version": "2019-05-16",
|
||||
"data": {
|
||||
"object": {
|
||||
"id": "sub_00000000000000",
|
||||
"object": "subscription",
|
||||
"application_fee_percent": null,
|
||||
"billing": "charge_automatically",
|
||||
"billing_cycle_anchor": 1560755136,
|
||||
"billing_thresholds": null,
|
||||
"cancel_at": null,
|
||||
"cancel_at_period_end": false,
|
||||
"canceled_at": null,
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1560755136,
|
||||
"current_period_end": 1563347136,
|
||||
"current_period_start": 1560755136,
|
||||
"customer": "cus_00000000000000",
|
||||
"days_until_due": null,
|
||||
"default_payment_method": "pm_1EmEuIGf8y65MteU7aM67eNH",
|
||||
"default_source": null,
|
||||
"default_tax_rates": [
|
||||
],
|
||||
"discount": null,
|
||||
"ended_at": null,
|
||||
"items": {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "si_00000000000000",
|
||||
"object": "subscription_item",
|
||||
"billing_thresholds": null,
|
||||
"created": 1560755136,
|
||||
"metadata": {
|
||||
},
|
||||
"plan": {
|
||||
"id": "plan_00000000000000",
|
||||
"object": "plan",
|
||||
"active": true,
|
||||
"aggregate_usage": null,
|
||||
"amount": 1000,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1560755040,
|
||||
"currency": "aud",
|
||||
"interval": "month",
|
||||
"interval_count": 1,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
},
|
||||
"nickname": "basic",
|
||||
"product": "prod_00000000000000",
|
||||
"tiers": null,
|
||||
"tiers_mode": null,
|
||||
"transform_usage": null,
|
||||
"trial_period_days": null,
|
||||
"usage_type": "licensed"
|
||||
},
|
||||
"quantity": 1,
|
||||
"subscription": "sub_00000000000000"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"total_count": 1,
|
||||
"url": "/v1/subscription_items?subscription=sub_FGmToAWeV09Z35"
|
||||
},
|
||||
"latest_invoice": "in_1EmEuKGf8y65MteUxapoByyd",
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
},
|
||||
"plan": {
|
||||
"id": "plan_00000000000000",
|
||||
"object": "plan",
|
||||
"active": true,
|
||||
"aggregate_usage": null,
|
||||
"amount": 1000,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1560755040,
|
||||
"currency": "aud",
|
||||
"interval": "month",
|
||||
"interval_count": 1,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
},
|
||||
"nickname": "basic",
|
||||
"product": "prod_00000000000000",
|
||||
"tiers": null,
|
||||
"tiers_mode": null,
|
||||
"transform_usage": null,
|
||||
"trial_period_days": null,
|
||||
"usage_type": "licensed"
|
||||
},
|
||||
"quantity": 1,
|
||||
"schedule": null,
|
||||
"start": 1560755136,
|
||||
"start_date": 1560755136,
|
||||
"status": "active",
|
||||
"tax_percent": null,
|
||||
"trial_end": null,
|
||||
"trial_start": null
|
||||
}
|
||||
}
|
||||
}
|
||||
134
server/test/customer.subscription.updated.json
Normal file
134
server/test/customer.subscription.updated.json
Normal file
@ -0,0 +1,134 @@
|
||||
{
|
||||
"created": 1326853478,
|
||||
"livemode": false,
|
||||
"id": "evt_00000000000000",
|
||||
"type": "customer.subscription.updated",
|
||||
"object": "event",
|
||||
"request": null,
|
||||
"pending_webhooks": 1,
|
||||
"api_version": "2019-05-16",
|
||||
"data": {
|
||||
"object": {
|
||||
"id": "sub_00000000000000",
|
||||
"object": "subscription",
|
||||
"application_fee_percent": null,
|
||||
"billing": "charge_automatically",
|
||||
"billing_cycle_anchor": 1560755136,
|
||||
"billing_thresholds": null,
|
||||
"cancel_at": null,
|
||||
"cancel_at_period_end": false,
|
||||
"canceled_at": null,
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1560755136,
|
||||
"current_period_end": 1563347136,
|
||||
"current_period_start": 1560755136,
|
||||
"customer": "cus_00000000000000",
|
||||
"days_until_due": null,
|
||||
"default_payment_method": "pm_1EmEuIGf8y65MteU7aM67eNH",
|
||||
"default_source": null,
|
||||
"default_tax_rates": [
|
||||
],
|
||||
"discount": null,
|
||||
"ended_at": null,
|
||||
"items": {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "si_00000000000000",
|
||||
"object": "subscription_item",
|
||||
"billing_thresholds": null,
|
||||
"created": 1560755136,
|
||||
"metadata": {
|
||||
},
|
||||
"plan": {
|
||||
"id": "plan_00000000000000",
|
||||
"object": "plan",
|
||||
"active": true,
|
||||
"aggregate_usage": null,
|
||||
"amount": 1000,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1560755040,
|
||||
"currency": "aud",
|
||||
"interval": "month",
|
||||
"interval_count": 1,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
},
|
||||
"nickname": "basic",
|
||||
"product": "prod_00000000000000",
|
||||
"tiers": null,
|
||||
"tiers_mode": null,
|
||||
"transform_usage": null,
|
||||
"trial_period_days": null,
|
||||
"usage_type": "licensed"
|
||||
},
|
||||
"quantity": 1,
|
||||
"subscription": "sub_00000000000000"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"total_count": 1,
|
||||
"url": "/v1/subscription_items?subscription=sub_FGmToAWeV09Z35"
|
||||
},
|
||||
"latest_invoice": "in_1EmEuKGf8y65MteUxapoByyd",
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
},
|
||||
"plan": {
|
||||
"id": "plan_00000000000000",
|
||||
"object": "plan",
|
||||
"active": true,
|
||||
"aggregate_usage": null,
|
||||
"amount": 1000,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1560755040,
|
||||
"currency": "aud",
|
||||
"interval": "month",
|
||||
"interval_count": 1,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
},
|
||||
"nickname": "basic",
|
||||
"product": "prod_00000000000000",
|
||||
"tiers": null,
|
||||
"tiers_mode": null,
|
||||
"transform_usage": null,
|
||||
"trial_period_days": null,
|
||||
"usage_type": "licensed"
|
||||
},
|
||||
"quantity": 1,
|
||||
"schedule": null,
|
||||
"start": 1560755136,
|
||||
"start_date": 1560755136,
|
||||
"status": "active",
|
||||
"tax_percent": null,
|
||||
"trial_end": null,
|
||||
"trial_start": null
|
||||
},
|
||||
"previous_attributes": {
|
||||
"plan": {
|
||||
"id": "OLD_00000000000000",
|
||||
"object": "plan",
|
||||
"active": true,
|
||||
"aggregate_usage": null,
|
||||
"amount": 1000,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1560755040,
|
||||
"currency": "aud",
|
||||
"interval": "month",
|
||||
"interval_count": 1,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
},
|
||||
"nickname": "basic",
|
||||
"product": "prod_00000000000000",
|
||||
"tiers": null,
|
||||
"tiers_mode": null,
|
||||
"transform_usage": null,
|
||||
"trial_period_days": null,
|
||||
"usage_type": "licensed",
|
||||
"name": "Old plan"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user