Such a story showed the date of the story before it, or caused a warning when it came first.
502 lines
15 KiB
PHP
502 lines
15 KiB
PHP
<?php
|
|
require_once('open_db.lib.php');
|
|
require_once(__DIR__ . '/lib/purify.inc.php');
|
|
require_once(__DIR__ . '/html.lib.php');
|
|
|
|
// The longest story HTML, in bytes. HTML Purifier needs seconds for a few megabytes.
|
|
define('MAX_STORY_BYTES', 65536);
|
|
|
|
function story_too_long($story) {
|
|
return strlen((string)$story) > MAX_STORY_BYTES;
|
|
}
|
|
|
|
function display_stories($id = NULL) {
|
|
global $db, $is_admin, $editable, $action;
|
|
if (is_null($id))
|
|
$id = get_id();
|
|
$search = get_search_args();
|
|
$page = '';
|
|
$nolink = NULL;
|
|
$posted = FALSE;
|
|
$date = '';
|
|
|
|
if ($action == 'preview') {
|
|
global $file_src;
|
|
$date = 'Awaiting posting';
|
|
$result = get_post_data(TRUE);
|
|
$nolink = TRUE;
|
|
$posted = TRUE;
|
|
} else if ($action == 'submit') {
|
|
$date = 'Posted '.gmdate("Y-m-d H:i");
|
|
if (!$is_admin) {
|
|
$result = get_post_data(TRUE);
|
|
$nolink = TRUE;
|
|
$posted = TRUE;
|
|
} else {
|
|
$result = get_query($id);
|
|
}
|
|
} else {
|
|
$result = get_query($id);
|
|
}
|
|
|
|
if (count($result) == 0) {
|
|
$page .= ''."\n";
|
|
if ($search) {
|
|
$page .= '<div class="box">'."\n";
|
|
$page .= '<p>No articles found.</p>
|
|
<p>Try searching again, or return to the <a
|
|
href="/">main page</a>. The search is case-insensitive, and
|
|
searches the title, author, date, and the text of each article.</p>'."\n";
|
|
$page .= '</div>'."\n";
|
|
} else {
|
|
$page .= '<div class="box">'."\n";
|
|
$page .= '<p>No articles found.</p>';
|
|
$page .= '</div>'."\n";
|
|
}
|
|
$page .= ''."\n";
|
|
return $page;
|
|
}
|
|
|
|
$page .= '<ul class="article-list">'."\n";
|
|
foreach ($result as $entry) {
|
|
if (empty($entry['id']) && isset($_SESSION['allow_queue_image'])) {
|
|
$entry['id'] = $_SESSION['allow_queue_image'];
|
|
}
|
|
// Escape everything that goes into the page
|
|
$entry_id = (int)$entry['id'];
|
|
$queue_url = $entry['queue_url'] ? 1 : 0;
|
|
$title = h($entry['title']);
|
|
$entry_date = $date;
|
|
if ($entry['date'])
|
|
$entry_date = '<time datetime="'.date("Y-m-d\TH:i:sP", strtotime($entry['date'])).'">' .
|
|
date("Y-m-d H:i", strtotime($entry['date'])) . '</time>';
|
|
$page .= '<li class="box">';
|
|
$page .= '<article>';
|
|
if ($is_admin && !$editable) {
|
|
$page .= '<span class="right">[<a href="/publish/edit?id='.$entry_id.'&queue='.$queue_url.'">edit</a>]</span>';
|
|
}
|
|
$page .= '<h3 class="articletitle">';
|
|
if ($nolink) {
|
|
$page .= $title;
|
|
} else if ($is_admin && $editable) {
|
|
$page .= '<a href="/publish/edit?id='.$entry_id.'&queue='.$queue_url.'">';
|
|
$page .= $title;
|
|
$page .= '</a>';
|
|
} else {
|
|
$page .= '<a href="/article?id='.$entry_id.'">';
|
|
$page .= $title;
|
|
$page .= '</a>';
|
|
}
|
|
$page .= '</h3>';
|
|
$page .= '<dl>';
|
|
if ($entry['hidden'])
|
|
$page .= '<span class="right red">[hidden]</span>';
|
|
$page .= '<dt class="author">By</dt> <dd class="author">' . h($entry['author']) . '</dd>';
|
|
$page .= '<dt class="date">Posted</dt> <dd class="date">' . $entry_date . '</dd>';
|
|
$page .= '</dl>';
|
|
if (isset($file_src)) {
|
|
$page .= '<img src="'.h($file_src).'" alt="'.$title.'" />';
|
|
} else if (!empty($entry['img']) || !empty($entry['uploaded'])) {
|
|
if ($editable) {
|
|
$page .= '<img src="/display?id='.$entry_id.'&queue='.$queue_url.'" alt="'.$title.'" />';
|
|
} else {
|
|
$page .= '<img src="/display?id='.$entry_id.'" alt="'.$title.'" />';
|
|
}
|
|
} else if ($posted && story_too_long($entry['story'])) {
|
|
$page .= '<p>The story is too long to show.</p>'."\n";
|
|
} else {
|
|
$page .= purify_story($entry['story'])."\n";
|
|
}
|
|
$page .= "</article>\n</li>\n";
|
|
}
|
|
$page .= '</ul>'."\n";
|
|
|
|
if (count($result) > 1)
|
|
$page .= get_pager();
|
|
|
|
return $page;
|
|
}
|
|
|
|
/*
|
|
* Return number of entries matching our request
|
|
*/
|
|
function get_count() {
|
|
global $db, $is_admin;
|
|
$search = get_search_args();
|
|
$queue = get_queue();
|
|
$table = which_table();
|
|
$params = array();
|
|
|
|
// if search= is defined, search....
|
|
if ($search) {
|
|
$count_query = "SELECT COUNT(*) FROM $table WHERE".
|
|
" (title LIKE ? OR author LIKE ? OR date LIKE ? OR story LIKE ?)";
|
|
$params = array_fill(0, 4, "%$search%");
|
|
// Otherwise, fall back to default view
|
|
} else {
|
|
$count_query = "SELECT COUNT(*) FROM ".$table." WHERE 1";
|
|
}
|
|
if (!$is_admin)
|
|
$count_query .= " AND hidden IS NOT TRUE";
|
|
|
|
//echo $count_query." <br />";
|
|
$row_count = NULL;
|
|
if ($count_query) {
|
|
if ($result = $db->execute_query($count_query, $params)) {
|
|
$row_count = $result->fetch_row();
|
|
$result->close();
|
|
} else {
|
|
db_fail();
|
|
}
|
|
}
|
|
return $row_count[0];
|
|
}
|
|
|
|
/*
|
|
* The story columns that the pages read. The image data stays in the
|
|
* database (display.php reads it): img is 1 for a story with an image,
|
|
* and NULL for one without.
|
|
*/
|
|
define('STORY_COLUMNS', 'id, title, author, email, date, story, hidden, mime, comments, editor, IF(LENGTH(img) > 0, 1, NULL) AS img');
|
|
|
|
/*
|
|
* Return array of results, correctly keyed to column names.
|
|
*/
|
|
function get_query($id = NULL) {
|
|
global $db, $is_admin;
|
|
$search = get_search_args();
|
|
$sort = get_sort_arg();
|
|
$order = get_order();
|
|
$start = get_start_num();
|
|
$show = get_show_num();
|
|
$queue = get_queue();
|
|
$table = which_table();
|
|
$results = array();
|
|
$params = array();
|
|
$limit = " LIMIT ".(int)$start." , ".(int)$show;
|
|
|
|
// If id= is defined, show the corresponding application only
|
|
if (is_numeric($id)) {
|
|
if (!$is_admin)
|
|
$query = "SELECT ".STORY_COLUMNS." FROM $table WHERE 1 AND hidden IS NOT TRUE AND id = ".(int)$id." LIMIT 0, 1";
|
|
else
|
|
$query = "SELECT ".STORY_COLUMNS." FROM $table WHERE 1 AND id = ".(int)$id." LIMIT 0, 1";
|
|
|
|
// if search= is defined, search....
|
|
} else if ($search) {
|
|
$query = "SELECT ".STORY_COLUMNS." FROM $table WHERE".
|
|
" (title LIKE ? OR author LIKE ? OR date LIKE ? OR story LIKE ?)";
|
|
$params = array_fill(0, 4, "%$search%");
|
|
if (!$is_admin)
|
|
$query .= " AND hidden IS NOT TRUE";
|
|
$query .= " ORDER BY ".$sort." ".$order.$limit;
|
|
// Otherwise, fall back to default view
|
|
} else {
|
|
$query = "SELECT ".STORY_COLUMNS." FROM ".$table." WHERE 1";
|
|
if (!$is_admin)
|
|
$query .= " AND hidden IS NOT TRUE";
|
|
$query .= " ORDER BY ".$sort." ".$order.$limit;
|
|
}
|
|
|
|
//echo $query;
|
|
if ($result = $db->execute_query($query, $params)) {
|
|
while ($row = $result->fetch_assoc()) {
|
|
$row['queue_url'] = $queue ? 1 : 0;
|
|
$results[] = $row;
|
|
}
|
|
$result->close();
|
|
} else {
|
|
db_fail();
|
|
}
|
|
return $results;
|
|
}
|
|
|
|
/*
|
|
* Return array of the POST variables we may care about
|
|
* (safer than extract($_POST))
|
|
*
|
|
* If $stripslashes is non-null, turn every value into a string:
|
|
* NULL (or an array) becomes string(""), int(0) becomes string("0").
|
|
* (It used to run stripslashes, but magic_quotes_gpc no longer exists.)
|
|
*
|
|
* If $html is non-null, run the output through htmlspecialchars,
|
|
* to translate HTML-unsafe characters. This should only be needed
|
|
* if you're outputting back to an HTML form.
|
|
*/
|
|
function get_post_data($stripslashes = NULL, $html = NULL) {
|
|
$queue = get_queue();
|
|
$results = array();
|
|
$entry['id'] = isset($_POST['id']) ? $_POST['id'] : NULL;
|
|
$entry['title'] = isset($_POST['title']) ? $_POST['title'] : NULL;
|
|
$entry['author'] = isset($_POST['author']) ? $_POST['author'] : NULL;
|
|
$entry['email'] = isset($_POST['email']) ? $_POST['email'] : NULL;
|
|
$entry['upload'] = isset($_POST['upload']) ? $_POST['upload'] : NULL;
|
|
$entry['story'] = isset($_POST['story']) ? $_POST['story'] : NULL;
|
|
$entry['comments'] = isset($_POST['comments']) ? $_POST['comments'] : NULL;
|
|
$entry['date'] = isset($_POST['date']) ? $_POST['date'] : NULL;
|
|
$entry['hidden'] = isset($_POST['hidden']) ? $_POST['hidden'] : NULL;
|
|
$entry['uploaded'] = isset($_POST['uploaded']) ? $_POST['uploaded'] : NULL;
|
|
$entry['editor'] = isset($_POST['editor']) ? $_POST['editor'] : NULL;
|
|
$entry['remove_img'] = isset($_POST['remove_img']) ? ($_POST['remove_img'] ? 1 : 0) : NULL;
|
|
$entry['queue_url'] = $queue ? 1 : 0;
|
|
|
|
if ($stripslashes) {
|
|
$entry = array_map('map_string', $entry);
|
|
}
|
|
if ($html) {
|
|
$entry = array_map('map_html', $entry);
|
|
}
|
|
$results[] = $entry;
|
|
return $results;
|
|
}
|
|
function map_string($value) {
|
|
return is_scalar($value) ? (string)$value : '';
|
|
}
|
|
function map_html($string) {
|
|
return htmlspecialchars(map_string($string), ENT_QUOTES, 'UTF-8', FALSE);
|
|
}
|
|
function unmap_html($string) {
|
|
return htmlspecialchars_decode($string, ENT_QUOTES);
|
|
}
|
|
|
|
/*
|
|
* Utility functions to sanitise data
|
|
*/
|
|
|
|
function get_id() {
|
|
global $db, $id;
|
|
if (is_numeric($id)) {
|
|
return $id;
|
|
} else if (isset($_REQUEST['id']) && is_numeric($_REQUEST['id'])) {
|
|
$id = $db->real_escape_string($_REQUEST['id']);
|
|
return $id;
|
|
} else {
|
|
return NULL;
|
|
}
|
|
}
|
|
|
|
function get_search_args() {
|
|
global $db;
|
|
if (isset($_GET['search']) && is_string($_GET['search'])) {
|
|
return $_GET['search'];
|
|
}
|
|
}
|
|
|
|
function get_order() {
|
|
global $db;
|
|
$order = NULL;
|
|
if (isset($_GET['order']) && is_string($_GET['order'])) {
|
|
$order = $db->real_escape_string($_GET['order']);
|
|
}
|
|
// Sort ascending by default
|
|
if ($order != "ASC" &&
|
|
$order != "DESC" &&
|
|
$order != "asc" &&
|
|
$order != "desc")
|
|
if (get_sort_arg() == "date") {
|
|
$order = "desc";
|
|
}
|
|
else $order = "asc";
|
|
return $order;
|
|
}
|
|
|
|
function get_sort_arg() {
|
|
global $db;
|
|
$sort = NULL;
|
|
if (isset($_GET['sort']) && is_string($_GET['sort'])) {
|
|
$sort = $db->real_escape_string($_GET['sort']);
|
|
}
|
|
if ($sort != "author" &&
|
|
$sort != "date" &&
|
|
$sort != "title")
|
|
$sort = "date";
|
|
return $sort;
|
|
}
|
|
|
|
// The most stories that one page can show
|
|
define('MAX_SHOW', 50);
|
|
|
|
// Return a whole number from the query string, limited to $min..$max,
|
|
// or NULL if the value is missing or is not a whole number
|
|
function get_int_arg($name, $min, $max = PHP_INT_MAX) {
|
|
if (!isset($_GET[$name]) || !is_string($_GET[$name]))
|
|
return NULL;
|
|
$value = filter_var(trim($_GET[$name]), FILTER_VALIDATE_INT);
|
|
if ($value === FALSE)
|
|
return NULL;
|
|
return max($min, min($max, $value));
|
|
}
|
|
|
|
function get_show_num() {
|
|
global $num_stories;
|
|
$show = get_int_arg('show', 1, MAX_SHOW);
|
|
if ($show !== NULL)
|
|
return $show;
|
|
else if ($num_stories)
|
|
return (int)$num_stories;
|
|
else // provide fallback
|
|
return 1;
|
|
}
|
|
|
|
function get_start_num() {
|
|
$start = get_int_arg('start', 0);
|
|
return $start === NULL ? 0 : $start;
|
|
}
|
|
|
|
/*
|
|
* Return the queue
|
|
*/
|
|
function get_queue() {
|
|
global $action, $queue;
|
|
if ($action == 'save' || $action == 'submit')
|
|
$queue = TRUE;
|
|
else if (isset($_REQUEST['queue']))
|
|
$queue = ($_REQUEST['queue'] === '1') ? TRUE : FALSE;
|
|
else if (!is_bool($queue))
|
|
$queue = FALSE;
|
|
return $queue;
|
|
}
|
|
|
|
/*
|
|
* Returns the table name
|
|
*/
|
|
function which_table($move = FALSE, $action = 'read') {
|
|
global $is_admin;
|
|
$queue = get_queue();
|
|
|
|
// Editing: a visitor can never write to the live table
|
|
if (!$is_admin && in_array($action, array('post', 'save', 'submit', 'delete'))) {
|
|
return 'queue';
|
|
} else if ($action == 'delete' && $move && $is_admin) {
|
|
return 'queue';
|
|
} else if ($action == 'post' && $is_admin) {
|
|
return 'xxx';
|
|
} else if ($action == 'save' || $action == 'submit') {
|
|
return 'queue';
|
|
|
|
// Viewing
|
|
} else if ($queue && $is_admin) {
|
|
return 'queue';
|
|
} else {
|
|
return 'xxx';
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Uploaded images wait in a directory outside the web root until the
|
|
* story is saved. The session holds the random file name, so the client
|
|
* never sends a file path.
|
|
*/
|
|
function upload_dir() {
|
|
$dir = rtrim(config('UPLOAD_DIR') ?: sys_get_temp_dir() . '/xxx-uploads', '/');
|
|
if (!is_dir($dir))
|
|
mkdir($dir, 0700, TRUE);
|
|
return $dir;
|
|
}
|
|
|
|
/*
|
|
* A size in megabytes from the configuration, in bytes. An empty, missing
|
|
* or bad value, or one that is not above zero, gives $default_mb.
|
|
*/
|
|
function config_megabytes($key, $default_mb) {
|
|
$value = trim((string)config($key, ''));
|
|
if (!is_numeric($value) || (float)$value <= 0)
|
|
$value = $default_mb;
|
|
return (int)((float)$value * 1024 * 1024);
|
|
}
|
|
|
|
/*
|
|
* The largest image a story can have, in bytes, from UPLOAD_MAX_MB (1 MB
|
|
* by default). NULL means no limit, which UPLOAD_MAX_MB=none gives.
|
|
*/
|
|
function upload_max_bytes() {
|
|
if (strtolower(trim((string)config('UPLOAD_MAX_MB', ''))) === 'none')
|
|
return NULL;
|
|
return config_megabytes('UPLOAD_MAX_MB', 1);
|
|
}
|
|
|
|
// The most that the upload directory holds, in bytes, from UPLOAD_DIR_LIMIT_MB (100 MB by default)
|
|
function upload_dir_limit_bytes() {
|
|
return config_megabytes('UPLOAD_DIR_LIMIT_MB', 100);
|
|
}
|
|
|
|
// The same in megabytes for a message, such as "1" or "2.5"
|
|
function upload_max_mb() {
|
|
return rtrim(rtrim(number_format(upload_max_bytes() / 1048576, 2, '.', ''), '0'), '.');
|
|
}
|
|
|
|
function is_upload_name($name) {
|
|
return is_string($name) && preg_match('/^[0-9a-f]{32}\z/', $name);
|
|
}
|
|
|
|
// Upload files older than this are abandoned previews
|
|
define('UPLOAD_MAX_AGE', 86400);
|
|
|
|
/*
|
|
* Move a file from $_FILES into the upload directory, and remember it
|
|
* in the session. Returns the new path, or FALSE. A session keeps one
|
|
* upload: a new one replaces the last. Abandoned uploads are removed
|
|
* first, and nothing is kept if the directory would grow past
|
|
* UPLOAD_DIR_LIMIT_MB (100 MB by default).
|
|
*/
|
|
function store_upload($tmp_file) {
|
|
prune_uploads();
|
|
remove_pending_upload();
|
|
if (upload_dir_size() + (int)@filesize($tmp_file) > upload_dir_limit_bytes())
|
|
return FALSE;
|
|
$name = bin2hex(random_bytes(16));
|
|
$file = upload_dir() . '/' . $name;
|
|
if (!move_uploaded_file($tmp_file, $file))
|
|
return FALSE;
|
|
$_SESSION['pending_upload'] = $name;
|
|
return $file;
|
|
}
|
|
|
|
// The upload files, name => path; nothing else in the directory counts
|
|
function upload_files() {
|
|
$files = array();
|
|
foreach (glob(upload_dir() . '/*') ?: array() as $file) {
|
|
if (is_upload_name(basename($file)) && is_file($file))
|
|
$files[basename($file)] = $file;
|
|
}
|
|
return $files;
|
|
}
|
|
|
|
function upload_dir_size() {
|
|
return array_sum(array_map(function ($file) { return (int)@filesize($file); }, upload_files()));
|
|
}
|
|
|
|
// Remove the uploads that are older than UPLOAD_MAX_AGE
|
|
function prune_uploads() {
|
|
foreach (upload_files() as $file) {
|
|
if (@filemtime($file) < time() - UPLOAD_MAX_AGE)
|
|
@unlink($file);
|
|
}
|
|
}
|
|
|
|
// Remove the file of this session's upload, and forget it
|
|
function remove_pending_upload() {
|
|
if ($file = pending_upload())
|
|
@unlink($file);
|
|
unset($_SESSION['pending_upload']);
|
|
}
|
|
|
|
/*
|
|
* Return the path of the image this session uploaded earlier, or FALSE.
|
|
*/
|
|
function pending_upload() {
|
|
$name = $_SESSION['pending_upload'] ?? NULL;
|
|
if (!is_upload_name($name))
|
|
return FALSE;
|
|
$file = upload_dir() . '/' . $name;
|
|
return is_file($file) ? $file : FALSE;
|
|
}
|
|
|
|
/*
|
|
* URL that shows the pending image; the name part stops browsers from
|
|
* showing an older upload from their cache.
|
|
*/
|
|
function pending_upload_url() {
|
|
return '/display?pending=' . substr($_SESSION['pending_upload'] ?? '', 0, 8);
|
|
}
|