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 }