colorful rat Ratfactor.com > Dave's Repos

famsite

A super tiny social site for families
git clone http://ratfactor.com/repos/famsite/famsite.git

famsite/fam.php

Download raw file: fam.php

1 <?php 2 # Note that www group needs write access to the upload dir 3 $GLOBALS['uploads_dir'] = '/htdocs/famsite/uploads/'; 4 $GLOBALS['db'] = new PDO('sqlite:/data/famsite.db'); 5 $GLOBALS['user_id'] = null; 6 7 # Make a DB query with optional bound parameters. 8 # Returns a PDOStatement object with these methods: 9 # fetch() 10 # fetchAll() 11 # rowCount() -- ONLY for rows affected by delete, insert, update 12 # 13 function dbq($query, $params=null, $debug=false) { 14 $sth = $GLOBALS['db']->prepare($query); 15 16 # This will avoid having both field names _and_ numeric indices. 17 $sth->setFetchMode(PDO::FETCH_ASSOC); 18 19 $result = $sth->execute($params); 20 21 if($debug){ 22 $sth->debugDumpParams(); 23 } 24 25 if(!$result){ 26 error_log("ERROR: Unable to execute query: " 27 . $sth->errorCode 28 . " " . print_r($sth->errorInfo(), true)); 29 error(500, "Fam-Error logged (DBQ)."); 30 } 31 32 return $sth; 33 } 34 35 # Returns user record OR redirects to not-logged-in message page. 36 function require_user() { 37 if(!isset($_COOKIE['login'])) { 38 header('Location: not-logged-in.html'); 39 exit; 40 } 41 42 $login = $_COOKIE['login']; 43 44 $q = dbq('select id, name from users where login = :login', 45 ['login'=>$login]); 46 $row = $q->fetch(); 47 48 if(!$row){ 49 # Not really sure what happened here because the login cookie 50 # *was* set, but it didn't match anything in the DB. 51 header('Location: not-logged-in.html?weird_login'); 52 exit; 53 } 54 55 $GLOBALS['user_id'] = $row['id']; 56 57 # Return user record 58 return $row; 59 } 60 61 function get_simple_user_list() { 62 $users = []; 63 $q = dbq('select id, name from users'); 64 $users_list = $q->fetchAll(); 65 foreach($users_list as &$u){ 66 $users[$u['id']] = $u; 67 } 68 return $users; 69 } 70 71 72 # Posts 73 # =================================================================== 74 75 $routes = []; 76 77 # Fetch Posts 78 # Re: Storing the "posted" and "updated" stamps as UTC, see 79 # https://ratfactor.com/cards/sqlite-today-date 80 # Particularly, "What, even, is a day" on why I'm not storing 81 # timezones with these. 82 $routes['GET posts'] = function ($params = []) { 83 $q = "SELECT p.rowid, 84 p.user, 85 p.txt, 86 p.posted, 87 p.updated, 88 p.touched, 89 p.filename, 90 (SELECT json_group_array( 91 json_object( 92 'rowid', r.rowid, 93 'user', r.user, 94 'emoji', r.emoji, 95 'txt', r.txt 96 )) 97 FROM reactions r 98 WHERE r.post = p.rowid 99 ) as reactions 100 FROM posts p 101 LEFT JOIN reactions r ON p.rowid = r.post 102 ?WHERE? 103 GROUP BY p.rowid 104 "; 105 106 # For testing front-end: delay response 107 if(isset($params['sleep']) && $params['sleep'] < 10){ 108 sleep($params['sleep']); 109 } 110 111 # Request is for a specific post by rowid 112 if(isset($params['rowid'])){ 113 $q = str_replace('?WHERE?', 'WHERE p.rowid = :rowid', $q); 114 $post = dbq($q, [ 'rowid' => $params['rowid'] ]); 115 return [$post->fetch()]; 116 } 117 118 # Request is for new posts after a specific post 'touched' value, 119 # which will give us any posts with edits, reactions, etc. 120 if(isset($params['after'])){ 121 $q = str_replace('?WHERE?', 'WHERE p.touched > :after', $q); 122 $posts = dbq($q, [ 'after' => $params['after'] ]); 123 return $posts->fetchAll(); 124 } 125 126 # Default: Return some recent posts 127 $q = str_replace('?WHERE?', '', $q); 128 $posts = dbq("$q ORDER BY p.rowid DESC LIMIT 30"); 129 return $posts->fetchAll(); 130 }; 131 132 # New Post 133 $routes['POST posts'] = function ($params){ 134 global $routes; 135 136 if(!is_numeric($params['parent'])){ 137 $params['parent'] = null; 138 } 139 140 $dest_fname = null; 141 142 # If there was an image file attached, move it to final location 143 if($_FILES['image']['size'] > 0){ 144 # Sanitize filename - alphanumeric only and prepend timestamp 145 $upload_fname = basename($_FILES['image']['name']); 146 $dest_fname = time() . '_' . preg_replace('[^a-zA-Z\d]', '', $upload_fname); 147 148 # Now save image (and thumbnail with 'tn_' prefix) 149 save_file_upload($_FILES['image'], $dest_fname); 150 save_file_upload($_FILES['thumb'], "tn_$dest_fname"); 151 } 152 153 # Get a new highest value for 'touched' field. Coalesce only needed on the 154 # very first post, but maybe this is the first post! 155 $touched = 'COALESCE((SELECT touched FROM posts ORDER BY touched DESC LIMIT 1), 0) + 1'; 156 157 $query = "INSERT INTO posts (user, txt, parent, posted, touched, filename) 158 VALUES (:user, :txt, :parent, unixepoch('now'), $touched, :filename) 159 RETURNING rowid"; 160 $result = dbq($query, [ 161 'user' => $GLOBALS['user_id'], 162 'txt' => $params['txt'], 163 'parent' => $params['parent'], 164 'filename' => $dest_fname, 165 ]); 166 167 $post_rowid = $result->fetch()['rowid']; 168 169 # Return the post we just inserted so it can be rendered 170 return $routes['GET posts'](['rowid' => $post_rowid]); 171 }; 172 173 # Update Post 174 # Note the singular "post" here instead of "posts" 175 $routes['POST post'] = function ($params){ 176 global $routes; 177 178 if(!is_numeric($params['rowid'])){ 179 error(400, "Can't edit a post without an id ('rowid')!"); 180 } 181 182 # New highest value for 'touched' field 183 $touched = '(SELECT touched FROM posts ORDER BY touched DESC LIMIT 1) + 1'; 184 185 $query = "UPDATE posts SET txt=:txt, updated=unixepoch('now'), touched=$touched 186 WHERE rowid=:rowid"; 187 $result = dbq($query, [ 188 'txt' => $params['txt'], 189 'rowid' => $params['rowid'], 190 ]); 191 192 # Return the post we just inserted so it can be rendered 193 return $routes['GET posts'](['rowid' => $params['rowid']]); 194 }; 195 196 # Add reaction 197 $routes['POST reaction'] = function ($params){ 198 global $routes; 199 200 $query = "INSERT INTO reactions (user, post, emoji, txt) 201 VALUES (:user, :post, :emoji, :txt)"; 202 $result = dbq($query, [ 203 'user' => $GLOBALS['user_id'], 204 'post' => $params['post'], # fkey to posts.rowid 205 'emoji' => $params['emoji'], 206 'txt' => $params['txt'], 207 ]); 208 209 # Update post also with a new highest value for 'touched' field 210 $touched = '(SELECT touched FROM posts ORDER BY touched DESC LIMIT 1) + 1'; 211 $query = "UPDATE posts SET touched=$touched WHERE rowid=:post"; 212 $result = dbq($query, ['post' => $params['post']]); 213 214 # Return the whole post we reacted to so it can be re-rendered 215 return $routes['GET posts'](['rowid' => $params['post']]); 216 }; 217 218 219 function save_file_upload($input_file, $dest_fname){ 220 $dest_path = $GLOBALS['uploads_dir'] . $dest_fname; 221 222 if(!move_uploaded_file($input_file['tmp_name'], $dest_path)){ 223 error(500, 'Failed to handle file upload.'); 224 } 225 } 226 227 class MyHttpErrorResponse extends Exception {} 228 function error($code, $msg){ 229 throw new MyHttpErrorResponse($msg, $code); 230 } 231 232 function get_exception(){ 233 error(400, 'bad request - which you asked for!'); 234 } 235 236 # ========================================================= 237 # If this page was called directly, it's an API request. 238 239 if(str_contains($_SERVER['REQUEST_URI'], 'fam.php')){ 240 require_user(); 241 $method = $_SERVER['REQUEST_METHOD']; 242 $resource = $_REQUEST['r']; 243 $fn = "{$method} {$resource}"; 244 if(!is_callable($routes[$fn])){ 245 http_response_code(404); 246 echo "No such {$method} method for resource '{$resource}'"; 247 exit; 248 } 249 250 try{ 251 $result = $routes[$fn]($_REQUEST); 252 echo json_encode($result); 253 exit; # return with HTTP 200 OK 254 } catch (MyHttpErrorResponse $e){ 255 error_log(print_r($e,true)); 256 257 } catch (Exception $e){ 258 error_log("Fam-Error for route '$fn':"); 259 error_log($e->getMessage() . "\n"); 260 // Have to do our own http_response_code() here 261 // MyHttpErrorResponse exception caught above! 262 http_response_code(500); 263 echo "Error logged for: $fn"; 264 exit; 265 } 266 }