1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
|
<?php
namespace mcoop;
require_once("config_cls.php");
require_once("vendor/busybee/urljoin/src/urljoin.php");
require_once("db_classes.php");
require_once("tables/members.php");
require_once("tables/dividend_credits.php");
require_once("tables/tasks.php");
require_once("tables/task_claims.php");
require_once("tables/task_dividend_credits.php");
require_once("tables/tcc_history.php");
// TODO: move this into the config object, or auto add to it in each decl file
$table_decls = array(
"members" => $members_table_decl,
"dividend_credits" => $dividend_credits_table_decl,
"tasks" => $tasks_table_decl,
"task_claims" => $task_claims_table_decl,
"task_dividend_credits" => $task_dividend_credits_table_decl,
"tcc_history" => $tcc_history_table_decl
);
class UnknownMember extends \Exception {
public $searched = null;
public $value = null;
function __construct($searched, $value) {
$this->searched = $searched;
$this->value = $value;
$message = "Unable to find a member with $searched = $value";
parent::__construct($message);
}
}
class RegistrationError extends \Exception {
public $invalid_part = null;
public $reason = null;
function __construct($invalid_part, $reason) {
$this->invalid_part = $invalid_part;
$this->reason = $reason;
$message = "Registration Error ($invalid_part): $reason";
parent::__construct($message);
}
}
class LoginError extends \Exception {}
class Database {
public $config = null;
public $conn = null;
public $target_tableversions = array(
"unexpected_errors" => 1,
);
public $statements = array();
public $upgraders = array();
public $table_decls = array();
function __construct($config) {
$this->config = $config;
global $table_decls;
$this->table_decls = $table_decls;
$this->connect();
}
function connect() {
// XXX/TODO: the exceptions from this need to be caught, see the warning on http://php.net/manual/en/pdo.connections.php
$this->conn = new \PDO(
$this->config->pdo_connstring,
$this->config->db_username,
$this->config->db_password,
array(\PDO::ATTR_PERSISTENT => true)
);
$this->create_tableversions_table();
$this->create_unexpected_errors_table();
// TODO: make table creation optional for everything except tableversions and unexpected_errors
// (you can change the $create argument to setup_table_decls to false for that now)
$this->setup_table_decls();
$this->auto_upgrade_all();
//$this->create_members_table();
//$this->upgraders["members"] = new MembersUpgrader($this->conn);
//$this->auto_upgrade_all();
//var_dump($this->conn);
}
function setup_table_decls($create=true) {
$conn = $this->conn;
foreach ($this->table_decls as $tname => $td) {
if ($create) {
$td->create_table($this);
}
$td->set_statements($this);
$clsname = $td->upgrader_classname;
$this->upgraders[$tname] = new $clsname($conn);
}
}
function get_cur_table_version($table_name) {
$this->statements["get_tableversion"]->execute(array(":name" => $table_name));
$x = $this->statements["get_tableversion"]->fetchAll(\PDO::FETCH_COLUMN);
$ver = (int)$x[0];
return $ver;
}
function auto_upgrade($upgrader) {
// This shouldn't be called until at the very least after create_tableversions_table, since it adds set_tableversion to statements
$table_name = $upgrader->table_name;
$old_ver = $this->get_cur_table_version($table_name);
$new_ver = $this->table_decls[$table_name]->newest_tableversion;
if ($old_ver < $new_ver) {
error_log("upgrading $table_name from $old_ver to $new_ver");
// XXX/TODO: this is assuming it doesn't error, going to get really ugly if it does
$upgraded_to = $upgrader->upgrade($old_ver, $new_ver);
$this->statements["set_tableversion"]->execute(array(":table_name" => $table_name, ":version" => $upgraded_to));
//print_r($upgraded_to);
return $upgraded_to;
}
}
function auto_upgrade_all() {
foreach ($this->upgraders as $table_name => $upgrader) {
$this->auto_upgrade($upgrader);
}
}
function create_tableversions_table() {
$conn = $this->conn;
$conn->exec("CREATE TABLE IF NOT EXISTS `table_versions` (`name` TEXT CHARACTER SET utf8 NOT NULL, `version` INT, PRIMARY KEY (`name`(1024)));");
$this->statements["set_tableversion"] = $conn->prepare("INSERT INTO table_versions VALUES (:table_name, :version) on duplicate key UPDATE version=:version");
$this->statements["get_tableversion"] = $conn->prepare("SELECT version FROM table_versions WHERE name = :name");
}
function create_unexpected_errors_table() {
// TODO: this table needs a datetime field so you know when the error happened
$conn = $this->conn;
$conn->exec("CREATE TABLE `unexpected_errors` (`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, `action` TEXT CHARACTER SET utf8 NOT NULL, `error_sql_code` CHAR(5) CHARACTER SET ascii NOT NULL, `error_server_code` INT, `error_server_description` TEXT CHARACTER SET utf8);");
$err_info = $conn->errorInfo();
//var_dump($err_info);
if ($err_info[0] == "00000") {
$this->statements["set_tableversion"]->execute(array(
":table_name" => "unexpected_errors",
":version" => $this->target_tableversions["unexpected_errors"]
));
}
$this->statements["insert_unexpected_error"] = $conn->prepare("INSERT INTO unexpected_errors (action, error_sql_code, error_server_code, error_server_description) VALUES (:action, :sql_code, :server_code, :server_description)");
}
function create_members_table() {
$conn = $this->conn;
$conn->exec(
"CREATE TABLE `members` (
`userid` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`username` TEXT CHARACTER SET utf8 NOT NULL,
`email` TEXT CHARACTER SET utf8 NOT NULL,
`last_updated_table_version` INT NOT NULL,
`full_name` TEXT CHARACTER SET utf8,
`validation_code` CHAR(64) CHARACTER SET ascii,
`validated` BOOL DEFAULT false,
`full_member` BOOL DEFAULT false,
`argon2_password_hash` VARCHAR(256) CHARACTER SET ascii,
`reset_password_hash` VARCHAR(256) CHARACTER SET ascii,
`reset_requested` BOOL DEFAULT false,
UNIQUE KEY (`username`(256)),
UNIQUE KEY (`email`(256))
);");
$err_info = $conn->errorInfo();
// the SQL errorcode for a table already existing seems to be 42S01
// but I think we don't need to check for it specifically here,
// just make sure there was no error
//print_r($err_info);
//var_dump($err_info);
if ($err_info[0] == "00000") {
$this->statements["set_tableversion"]->execute(array(
":table_name" => "members",
":version" => $this->target_tableversions["members"]
));
} else if ($err_info[0] != "42S01") {
$this->statements["insert_unexpected_error"]->execute(array(
":action" => "create_members_table",
":sql_code" => $err_info[0],
":server_code" => $err_info[1],
":server_description" => $err_info[2]
));
}
$this->statements["register_member"] = $conn->prepare("INSERT INTO members (username, email, last_updated_table_version, full_name, argon2_password_hash) VALUES (:username, :email, :version, :full_name, :argon2_phash)");
$this->statements["get_member_passhash_by_username"] = $conn->prepare("SELECT argon2_password_hash FROM members WHERE username = :username");
$this->statements["get_member_by_username"] = $conn->prepare("SELECT * FROM members WHERE username = ?");
$this->statements["get_member_by_email"] = $conn->prepare("SELECT * FROM members WHERE email = ?");
$this->statements["get_members_by_uname_or_email"] = $conn->prepare("SELECT * FROM members WHERE username = :username OR email = :email");
}
function validate_username($username) {
if (!preg_match("/^[a-zA-Z0-9]{3,}$/", $username)) {
throw new RegistrationError("username", "Invalid username (must be at least 3 characters long, only english alphanumeric characters are allowed");
}
$conn = $this->conn;
$username_check = $conn->prepare("SELECT * FROM members WHERE username = :username");
$username_check->execute(array(":username" => $username));
$x = $username_check->fetchAll(\PDO::FETCH_COLUMN);
if ($x) {
throw new RegistrationError("username", "Username already registered");
}
return $username;
}
function validate_email($email) {
$filt_email = filter_var($email, FILTER_VALIDATE_EMAIL);
if ($filt_email != $email) {
throw new RegistrationError("email", "Doesn't validate as a valid email address");
}
$conn = $this->conn;
$email_check = $conn->prepare("SELECT * FROM members where email = :email");
$email_check->execute(array(":email" => $filt_email));
$x = $email_check->fetchAll(\PDO::FETCH_COLUMN);
if ($x) {
throw new RegistrationError("email", "An account with that email address already exists, you can <a href=\"/reset_password.php\">Reset your password</a> if you need a new password");
}
return $filt_email;
}
function validate_fullname($full_name) {
$filt_fname = filter_var($full_name, FILTER_SANITIZE_STRING, array(
"flags" => FILTER_FLAG_NO_ENCODE_QUOTES | FILTER_FLAG_STRIP_LOW | FILTER_FLAG_STRIP_HIGH | FILTER_FLAG_STRIP_BACKTICK
));
return $filt_fname;
}
// TODO: reset password functionality that sends a randomly generated password to your email address
function register($username, $email, $password, $full_name, $validate_email=false, $email_twig_env=null) {
$filt_email = $this->validate_email($email);
$filt_username = $this->validate_username($username);
$filt_fname = $this->validate_fullname($full_name);
$statements = $this->statements;
$conn = $this->conn;
$pass_hash = password_hash($password, PASSWORD_ARGON2I);
$conn->beginTransaction();
$success = $statements["register_member"]->execute(array(
":username" => $username,
":email" => $filt_email,
":version" => $this->table_decls["members"]->newest_tableversion,
":full_name" => $filt_fname,
":argon2_phash" => $pass_hash
));
// TODO: check that it was actually successful
if (!$success) {
$einfo = $statements["register_member"]->errorInfo();
error_log("mcoop member registration failed inserting into the database: " . var_export($einfo, true) . " ($username, $email, $password, $pass_hash, $full_name)");
throw new RegistrationError("database", "registration failed internally, contact the admin");
}
if ($validate_email) {
$this->send_validation_email($username, $filt_email, $email_twig_env);
}
$conn->commit();
// TODO: setup the login session here too, not sure if session_start should be called in the script setup code actually instead of just here (and just set the variable in $_SESSION here)
//session_start();
$_SESSION["logged_in"] = true;
$_SESSION["login_username"] = $username;
// TODO: include login time in here and other things so you can invalidate sessions/log people out if a password reset gets requested or anything else
//session_write_close();
}
function send_validation_email($username, $email, $email_twig_env) {
// generate a validation code, just use some random bytes, hash them and use the hexdigest for now
$rbytes = random_bytes(64);
$h = hash("sha256", $rbytes, false);
$conn = $this->conn;
$stmt = $conn->prepare("UPDATE members SET validation_code = :vcode WHERE username = :username AND email = :email");
$success = $stmt->execute(array(
":vcode" => $h,
":username" => $username,
":email" => $email
));
if (!$success) {
$einfo = $stmt->errorInfo();
error_log("mcoop: send_validation_email failed updating members: " . var_export($einfo, true) . " ($h, $username, $email)");
throw new RegistrationError("database", "internal error generating validation code, contact the admin");
}
$headers = "From: " . $this->config->email_from_address;
$sitename = $this->config->website_name;
$vurl = urljoin($this->config->webapp_base_uri, "validate.php?un=$username&vcode=$h");
$deactivate_url = urljoin($this->config->webapp_base_uri, "deactivate.php?un=$username&vcode=$h");
$body = $email_twig_env->render("validation_email.tmpl", array(
"email" => $email,
"website_name" => $sitename,
"vurl" => $vurl,
"deactivate_url" => $deactivate_url
));
mail($email, "Activate your $sitename account", $body, $headers);
}
function login($username, $password, $session_info) {
$m = DBMember::load_by($this, "username", $username);
// TODO: password reset logic
if (password_verify($password, $m->argon2_password_hash)) {
$_SESSION["logged_in"] = true;
$_SESSION["login_username"] = $username;
$session_info->re_init();
return true;
} else {
throw new LoginError("Invalid Password");
}
}
function get_members($only_verified=true) {
}
}
?>
|