flush -- WIP admin panel

This commit is contained in:
Tony Yang 2019-05-30 14:18:07 +08:00
parent 75af7df7b4
commit 84fb4180c9
Signed by: t510599
GPG Key ID: D88388851C28715D
53 changed files with 9104 additions and 2 deletions

View File

@ -1,2 +1,11 @@
# cavern
A simple personal blog system.
# Cavern
> Explore those deep inside the cave.
A simple blog system.
## Feature
## Requirements
## Install
## Libraries

223
account.php Normal file
View File

@ -0,0 +1,223 @@
<?php
require_once('connection/SQL.php');
require_once('config.php');
require_once('include/view.php');
require_once('include/security.php');
if (isset($_POST['username']) && trim($_POST['username']) != "" && isset($_POST['password']) && isset($_POST['name']) && isset($_POST['email'])) {
// create new account
if (!validate_csrf()) {
http_response_code(403);
header('axios-location: account.php?new');
exit;
}
$username = $_POST['username'];
$exist = cavern_query_result("SELECT * FROM `user` WHERE `username`='%s' OR `email`='%s'", array($username, $_POST["email"]))['num_rows'];
if ($exist == 0) {
if (preg_match('/^[a-z][a-z0-9\_\-]*$/', $username) && strlen($username) <= 20 && strlen($_POST['name']) <= 40 && filter_var($_POST['email'], FILTER_VALIDATE_EMAIL)) {
$SQL->query("INSERT INTO `user` (`username`, `pwd`, `name`, `email`) VALUES ('%s', '%s', '%s', '%s')", array($username, cavern_password_hash($_POST['password'], $username), htmlspecialchars($_POST['name']), $_POST['email']));
header('axios-location: index.php?ok=reg');
} else {
http_response_code(400);
header('axios-location: index.php?err=miss');
}
exit;
} else {
http_response_code(409); // 409 Conflict
header('axios-location: account.php?new&err=used');
exit;
}
} else if (isset($_SESSION['cavern_username']) && isset($_POST['username']) && isset($_POST['old']) && (isset($_POST['name']) || isset($_POST['new']))) {
// modify account data
if (!validate_csrf()) {
http_response_code(403);
header('axios-location: account.php');
exit;
}
$username = $_POST['username'];
if ($username != $_SESSION['cavern_username']) {
// not the same person
http_response_code(403);
header('axios-location: account.php?err=edit');
exit;
} else {
// confirm old password and mofify account data
$original = cavern_query_result("SELECT * FROM `user` WHERE `username`='%s'", array($username));
if (!hash_equals(cavern_password_hash($_POST['old'], $username), $original['row']['pwd']) || $original['num_rows'] == 0) {
http_response_code(403);
header('axios-location: account.php?err=old');
exit;
} else {
if (trim($_POST['new']) != '') {
$password = cavern_password_hash($_POST['new'], $username);
$SQL->query("UPDATE `user` SET `pwd`='%s' WHERE `username`='%s'", array($password, $username));
}
if (trim($_POST['name']) != '' && strlen($_POST['name']) <= 40) {
$SQL->query("UPDATE `user` SET `name`='%s' WHERE `username`='%s'", array(htmlspecialchars($_POST['name']), $username));
} else {
http_response_code(400);
header('axios-location: account.php?err=miss');
exit;
}
if (trim($_POST['email']) != '' && filter_var($_POST['email'], FILTER_VALIDATE_EMAIL)) {
$emailExist = cavern_query_result("SELECT * FROM `user` WHERE NOT `username`='%s' AND `email`='%s'", array($username, $_POST["email"]));
if ($emailExist['num_rows'] == 0) {
$SQL->query("UPDATE `user` SET `email`='%s' WHERE `username`='%s'", array($_POST['email'], $username));
} else {
http_response_code(400);
header('axios-location: account.php?err=used');
exit;
}
} else {
http_response_code(400);
header('axios-location: account.php?err=miss');
exit;
}
header('axios-location: account.php?ok=edit');
exit;
}
}
} else if (!isset($_SESSION['cavern_username']) && !isset($_GET['new'])) {
// if mode isn't definded, redirect to register page
header('Location: account.php?new');
exit;
} else if (isset($_SESSION['cavern_username']) && isset($_GET['new'])) {
// if someone is logged in, then redirect to account setting page
header('Location: account.php');
exit;
}
// create new account
if (isset($_GET['new'])) {
$view = new View('theme/default.html', 'theme/nav/default.html', 'theme/sidebar.php', $blog['name'], "註冊");
if (!$blog['register']) {
$view->show_message('inverted negative', "抱歉,目前暫停註冊");
$view->render();
exit;
}
if (isset($_GET['err'])) {
if ($_GET['err'] == "miss") {
$view->show_message('inverted negative', "請正確填寫所有欄位");
} else if ($_GET['err'] == "used") {
$view->show_message('inverted negative', "此使用者名稱或是信箱已被使用");
}
}
$view->add_script("./include/js/security.js");
$view->add_script("./include/js/account.js");
?>
<form action="account.php" method="POST" name="newacc" autocomplete="off">
<div class="ts form">
<div class="ts big dividing header">註冊</div>
<div class="required field">
<label>帳號</label>
<input required="required" name="username" maxlength="20" pattern="^[a-z][a-z0-9_-]*$" type="text">
<small>上限20字元 (小寫英文、數字、底線以及連字號)。首字元必須為英文。</small>
<small>你未來將無法更改這項設定。</small>
</div>
<div class="required field">
<label>暱稱</label>
<input required="required" name="name" maxlength="40" type="text">
<small>上限40字元。</small>
</div>
<div class="required field">
<label>密碼</label>
<input required="required" name="password" type="password">
</div>
<div class="required field">
<label>重複密碼</label>
<input required="required" name="repeat" type="password">
</div>
<div class="required field">
<label>信箱</label>
<input required="required" name="email" type="email">
<small>用於辨識頭貼。Powered by <a href="https://en.gravatar.com/" target="_blank">Gravatar</a></small>
</div>
<input class="ts right floated primary button" value="送出" type="submit">
</div>
</form>
<?php
$view->render();
} else {
// edit account data
$username = $_SESSION['cavern_username'];
$result = cavern_query_result("SELECT * FROM `user` WHERE `username`='%s'", array($username));
$name = $result['row']['name'];
$email = $result['row']['email'];
$view = new View('theme/default.html', 'theme/nav/util.php', 'theme/sidebar.php', $blog['name'], "帳號");
$view->add_script_source("ts('.ts.dropdown').dropdown();");
$view->add_script("./include/js/security.js");
$view->add_script("./include/js/account.js");
if (isset($_GET['err'])) {
switch ($_GET['err']) {
case 'edit':
$view->show_message('inverted negative', "修改失敗");
break;
case 'old':
$view->show_message('inverted negative', "舊密碼錯誤");
break;
case "miss":
$view->show_message('inverted negative', "請正確填寫所有欄位");
break;
case "used":
$view->show_message('inverted negative', "此信箱已被其他帳號使用");
break;
}
}
if (isset($_GET['ok'])) {
if ($_GET['ok'] == "edit") {
$view->show_message('inverted positive', "修改成功!");
}
}
?>
<form action="account.php" method="POST" name="editacc">
<div class="ts form">
<div class="ts big dividing header">編輯帳號</div>
<div class="fields">
<div class="six wide field">
<label>頭貼</label>
<div class="ts center aligned flatted borderless segment">
<img src="https://www.gravatar.com/avatar/<?= md5(strtolower($email)) ?>?d=https%3A%2F%2Ftocas-ui.com%2Fassets%2Fimg%2F5e5e3a6.png&s=500" class="ts rounded image" id="avatar">
</div>
<div data-tooltip="請透過電子信箱更換頭貼" data-tooltip-position="bottom right" class="ts top right attached label avatar tooltip">?</div>
</div>
<div class="ten wide field">
<div class="disabled field">
<label>帳號</label>
<input type="text" name="username" value="<?= $username ?>">
</div>
<div class="required field">
<label>暱稱</label>
<input type="text" required="required" name="name" maxlength="40" value="<?= $name ?>">
<small>上限40字元。</small>
</div>
</div>
</div>
<div class="required field">
<label>信箱</label>
<input type="email" required="required" name="email" value="<?= $email ?>">
<small>透過電子信箱,在 <a href="https://en.gravatar.com/" target="_blank">Gravatar</a> 更改你的頭貼。</small>
</div>
<div class="required field">
<label>舊密碼</label>
<input type="password" required="required" name="old">
</div>
<div class="field">
<label>新密碼</label>
<input type="password" name="new">
<small>留空則不修改。</small>
</div>
<div class="field">
<label>重複密碼</label>
<input name="repeat" type="password">
<small>重複新密碼。</small>
</div>
<input type="submit" class="ts right floated primary button" value="送出">
</div>
</form>
<?php $view->render();
}
?>

60
admin/ajax/config.php Normal file
View File

@ -0,0 +1,60 @@
<?php
set_include_path('../../include/');
$includepath = TRUE;
require_once('../../config.php');
require_once('../../connection/SQL.php');
require_once('user.php');
require_once('security.php');
$user = validate_user();
if (!$user->valid) {
send_error(403, "novalid");
} else if (!($user->level >= 8)) {
send_error(403, "nopermission");
}
if ($_SERVER["REQUEST_METHOD"] == "POST") {
// modify blog settings
if (!validate_csrf()) {
send_error(403, "csrf");
}
$config_filename = "../../config.php";
$template_filename = "../../config.template";
if (!is_writable($config_filename)) {
send_error(500, "notwritable");
}
try {
$limit = abs(intval(@$_POST["limit"]));
$content = file_get_contents($template_filename);
$new_content = strtr($content, array(
"{blog_name}" => addslashes(@$_POST["name"]),
"{limit}" => ($limit != 0 ? $limit : 10),
"{register}" => (@$_POST["register"] === "true" ? "true" : "false")
));
file_put_contents($config_filename, $new_content);
$result = json_encode(array('status' => TRUE, "time" => round($_SERVER["REQUEST_TIME_FLOAT"] * 1000)));
} catch (Exception $e) {
http_response_code(500);
$result = json_encode(array('status' => $e->getMessage(), "time" => round($_SERVER["REQUEST_TIME_FLOAT"] * 1000)));
}
header('Content-Type: application/json');
echo $result;
exit;
} else if ($_SERVER["REQUEST_METHOD"] == "GET") {
// fetch settings
header('Content-Type: application/json');
echo json_encode(array_merge(array('status' => TRUE, "time" => round($_SERVER["REQUEST_TIME_FLOAT"] * 1000)), $blog));
exit;
}
function send_error($code, $message) {
http_response_code($code);
header('Content-Type: application/json');
echo json_encode(array('status' => $message));
exit;
}

20
admin/ajax/statistics.php Normal file
View File

@ -0,0 +1,20 @@
<?php
set_include_path('../../include/');
$includepath = TRUE;
require_once('../../config.php');
require_once('../../connection/SQL.php');
require_once('user.php');
$user = validate_user();
if (!$user->valid) {
send_error(403, "novalid");
} else if (!($user->level >= 8)) {
send_error(403, "nopermission");
}
$post_count = intval(cavern_query_result("SELECT COUNT(*) AS `count` FROM `post`")['row']['count']);
$user_count = intval(cavern_query_result("SELECT COUNT(*) AS `count` FROM `user`")['row']['count']);
$comment_count = intval(cavern_query_result("SELECT COUNT(*) AS `count` FROM `comment`")['row']['count']);
header('Content-Type: application/json');
echo json_encode(array("fetch" => round($_SERVER["REQUEST_TIME_FLOAT"] * 1000), "name" => $blog['name'], "post" => $post_count, "user" => $user_count, "comment" => $comment_count));

114
admin/ajax/user.php Normal file
View File

@ -0,0 +1,114 @@
<?php
set_include_path('../../include/');
$includepath = TRUE;
require_once('../../config.php');
require_once('../../connection/SQL.php');
require_once('user.php');
require_once('security.php');
$user = validate_user();
if (!$user->valid) {
send_error(403, "novalid");
} else if (!($user->level >= 8)) {
send_error(403, "nopermission");
}
if ($_SERVER["REQUEST_METHOD"] == "POST") {
// modify account data
if (!validate_csrf()) {
send_error(403, "csrf");
}
if (isset($_POST['username']) && (isset($_POST['name']) || isset($_POST['password']))) {
// modify account data
$username = trim($_POST['username']);
try {
$target_user = new User($username);
} catch (NoUserException $e) {
send_error(404, "nouser");
}
if (trim($_POST['password']) != '') {
$password = cavern_password_hash($_POST['password'], $username);
$SQL->query("UPDATE `user` SET `pwd`='%s' WHERE `username`='%s'", array($password, $username));
}
if (trim($_POST['name']) != '' && strlen($_POST['name']) <= 40) {
$SQL->query("UPDATE `user` SET `name`='%s' WHERE `username`='%s'", array(htmlspecialchars($_POST['name']), $username));
} else {
send_error(400, "noname");
}
if (trim($_POST['email']) != '' && filter_var($_POST['email'], FILTER_VALIDATE_EMAIL)) {
$emailExist = cavern_query_result("SELECT * FROM `user` WHERE NOT `username`='%s' AND `email`='%s'", array($username, $_POST["email"]));
if ($emailExist['num_rows'] == 0) {
$SQL->query("UPDATE `user` SET `email`='%s' WHERE `username`='%s'", array($_POST['email'], $username));
} else {
send_error(400, "emailused");
}
} else {
send_error(400, "noemail");
}
$SQL->query("UPDATE `user` SET `muted`='%d' WHERE `username`='%s'", array($_POST['muted'], $username));
header("Content-Type: application/json");
echo json_encode(array("status" => TRUE, "modified" => $username));
exit;
}
} else if ($_SERVER["REQUEST_METHOD"] == "GET") {
// fetch user list (we can fetch single user data from ajax)
$user_list = array();
$user_query = cavern_query_result("SELECT * FROM `user`", array());
if ($user_query['num_rows'] > 0) {
do {
$data = $user_query['row'];
$user_list[] = array(
"id" => intval($data['id']),
"username" => $data['username'],
"name" => $data['name'],
"email" => $data['email'],
"role" => cavern_level_to_role($data['level'])
);
} while ($user_query['row'] = $user_query['query']->fetch_assoc());
}
header('Content-Type: application/json');
echo json_encode(array('status' => TRUE, "time" => round($_SERVER["REQUEST_TIME_FLOAT"] * 1000), "list" => $user_list));
exit;
} else if ($_SERVER["REQUEST_METHOD"] == "DELETE") {
// delete user account
$username = trim($_GET['username']);
try {
$target_user = new User($username);
} catch (NoUserException $e) {
send_error(404, "nouser");
}
// you cannot delete site owner
if ($target_user->level === 9) {
send_error(403, "deleteowner");
}
/* cleanup user data */
// Although we set foreign key, in fact `ON CASCADE` cannot fire trigger
// like cleanup
$SQL->query("DELETE FROM `like` WHERE `username`='%s'", array($target_user->username));
// comment cleanup
$SQL->query("DELETE FROM `comment` WHERE `username`='%s'", array($target_user->username));
// now we can delete the user data
$SQL->query("DELETE FROM `user` WHERE `username`='%s'", array($target_user->username));
header('Content-Type: application/json');
echo json_encode(array('status' => TRUE, "time" => round($_SERVER["REQUEST_TIME_FLOAT"] * 1000), "deleted" => $username));
exit;
}
function send_error($code, $message) {
http_response_code($code);
header('Content-Type: application/json');
echo json_encode(array('status' => $message));
exit;
}

17
admin/component/config.js Normal file
View File

@ -0,0 +1,17 @@
(() => {
function create(tag) {
return document.createElement(tag);
}
pageManager.register("config", function () {
return {
render: function (...args) {
pageManager.setHeader("設定");
setTimeout(() => {
pageManager.document.innerHTML = "config";
pageManager.setLoaderState(false)
}, 500);
}
}
});
})();

17
admin/component/post.js Normal file
View File

@ -0,0 +1,17 @@
(() => {
function create(tag) {
return document.createElement(tag);
}
pageManager.register("post", function () {
return {
render: function (...args) {
pageManager.setHeader("文章");
setTimeout(() => {
pageManager.document.innerHTML = args[0] + args[1];
pageManager.setLoaderState(false)
}, 500);
}
}
});
})();

View File

@ -0,0 +1,70 @@
(() => {
function fetchStatistics() {
axios.request({
method: "GET",
url: "ajax/statistics.php"
}).then(function (res) {
renderPage(res.data);
}).catch(function (err) {
if (err.response) {
console.error(err.response.status);
}
});
}
function renderPage(data) {
let iconName = {
"post": "file text",
"user": "users",
"comment": "comments"
};
let statLabel = {
"post": "文章總數",
"user": "使用者總數",
"comment": "留言總數"
};
let statTemplate = `<div class="ts left aligned statistic"><div class="value">{{ value }}</div><div class="label">{{ label }}</div></div>`
let cardContainer = create('div'); cardContainer.className = "ts stackable three cards";
for (let key in data) {
if (Object.keys(statLabel).indexOf(key) != -1) {
let card = create('div'); card.className = "ts card";
let content = create('div'); content.className = "content";
let symbol = create('div'); symbol.className = "symbol";
let icon = create('i'); icon.className = `${iconName[key]} icon`;
content.innerHTML = statTemplate.replace("{{ value }}", data[key]).replace("{{ label }}", statLabel[key]);
symbol.appendChild(icon);
card.appendChild(content);
card.appendChild(symbol);
cardContainer.appendChild(card);
} else if (key === "name") {
// blog name
}
}
// finish up
setTimeout(() => {
pageManager.document.innerHTML = "";
pageManager.document.appendChild(cardContainer);
pageManager.setLoaderState(false)
}, 500);
}
function create(tag) {
return document.createElement(tag);
}
pageManager.register("statistics", function () {
return {
render: function (...args) {
pageManager.setHeader("總覽");
fetchStatistics();
}
}
});
})();

17
admin/component/user.js Normal file
View File

@ -0,0 +1,17 @@
(() => {
function create(tag) {
return document.createElement(tag);
}
pageManager.register("user", function () {
return {
render: function (...args) {
pageManager.setHeader("使用者");
setTimeout(() => {
pageManager.document.innerHTML = args[0];
pageManager.setLoaderState(false)
}, 500);
}
}
});
})();

114
admin/dashboard.js Normal file
View File

@ -0,0 +1,114 @@
$(document).ready(function () {
var $sidebar = $('.ts.sidebar');
let mq = window.matchMedia("(max-width: 768px)");
mq.addListener(sidebarOnMobile);
sidebarOnMobile(mq); // run first
function sidebarOnMobile(q) {
if (q.matches) {
/* mobile -> hide sidebar */
$sidebar.toggleClass("animating", true);
$sidebar.toggleClass("visible", false);
} else {
/* non-mobile -> show sidebar */
$sidebar.toggleClass("animating", false);
$sidebar.toggleClass("visible", true);
}
}
$('button#toggleSidebar').on('click', function (e) {
$sidebar.toggleClass('visible');
});
});
function Manager(element) {
this.components = {};
this.cache = {}; // args cache for components just be on load
this.document = element;
}
Manager.prototype = {
load: function(name, ...args) {
this.setLoaderState(true);
if (Object.keys(this.components).indexOf(name) == -1) {
// cache args
this.cache[name] = args;
script = document.createElement("script");
script.src = `component/${name}.js`;
document.body.appendChild(script);
} else {
this.components[name].render(...args);
}
},
register: function(name, init) {
this.components[name] = init();
let args = this.cache[name];
this.components[name].render(...args);
delete this.cache[name];
},
setHeader: function (title) {
$('#header').text(title);
},
setLoaderState: function (state) {
$('.pusher .dimmer').toggleClass('active', state);
}
}
let pageManager = new Manager(document.querySelector('#content'));
var root = "./", useHash = true, hash = "#";
let router = new Navigo(root, useHash, hash);
if (location.href.slice(-1) === "/") {
// catch to navigo
router.navigate("/");
}
router.on({
"/": function () {
// system overview
render("statistics");
},
"/post": function () {
render("post", "page", 1);
},
"/post/:pid": function (params) {
render("post", "pid", params.pid);
},
"/post/page/:page": function (params) {
render("post", "page", params.page);
},
"/user": function () {
render("user", "list");
},
"/user/add": function () {
render("user", "add");
},
"/user/:username": function (params) {
render("user", "username", params.username);
},
"/config": function () {
render("config");
}
}).resolve();
router.updatePageLinks();
function render(page, ...args) {
switch (page) {
case "user":
pageManager.load("user", ...args);
break;
case "post":
pageManager.load("post", ...args);
break;
case "config":
pageManager.load("config", ...args);
break;
case "statistics":
default:
pageManager.load("statistics", ...args);
break;
}
}

28
admin/index.php Normal file
View File

@ -0,0 +1,28 @@
<?php
set_include_path('../include/');
$includepath = TRUE;
require_once('../connection/SQL.php');
require_once('../config.php');
require_once('view.php');
require_once('security.php');
require_once('user.php');
$user = validate_user();
if (!$user->valid) {
http_response_code(403);
header("Location: ../index.php?err=account");
exit;
}
if (!$user->islogin) {
http_response_code(401);
header('Location: ../login.php?next=admin');
exit;
} else if ($user->level < 8) {
http_response_code(403);
header('Location: ../index.php?err=permission');
exit;
}
$view = new View('./theme/dashboard.html', 'theme/avatar.php', '', $blog['name'], "管理介面");
$view->render();

38
admin/theme/admin.css Normal file
View File

@ -0,0 +1,38 @@
/* nav bar */
button#toggleSidebar {
border-radius: 0;
border-bottom-right-radius: .28571rem;
visibility: hidden;
}
.pusher > nav {
z-index: 14;
position: sticky;
position: -webkit-sticky; /* safari */
left: 0;
top: 0;
}
/* content */
#header {
margin-top: 5px;
}
@media (max-width: 768px) {
.ts.visible.sidebar:not(.overlapped) ~ .pusher.squeezable {
/* RWD fix */
width: 100vw;
left: 0;
}
button#toggleSidebar {
visibility: visible;
}
.visible.sidebar ~ .pusher > nav {
width: calc(100vw - 230px);
transform: translate3d(230px, 0, 0);
transition: transform .45s cubic-bezier(0.23, 1, 0.32, 1);
will-change: transform;
}
}

12
admin/theme/avatar.php Normal file
View File

@ -0,0 +1,12 @@
<?php
require_once('../config.php');
require_once('../connection/SQL.php');
require_once('../include/user.php');
$user = validate_user();
?>
<div class="center aligned item">
<img class="ts tiny circular image" src="https://www.gravatar.com/avatar/<?= md5($user->email) ?>?d=https%3A%2F%2Ftocas-ui.com%2Fassets%2Fimg%2F5e5e3a6.png&s=150">
<br><br>
<div><?= $user->name ?></div>
</div>

View File

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Tocas UICSS 與元件 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tocas-ui/2.3.3/tocas.css">
<!-- Tocas JS模塊與 JavaScript 函式 -->
<script src="../include/js/lib/tocas.js"></script>
<!-- jQuery -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@7.26.11/dist/sweetalert2.all.min.js"></script>
<link rel="stylesheet" href="theme/admin.css">
<title>{part} | {title}</title>
</head>
<body>
<div class="ts left vertical fluid inverted visible menu sidebar">
{nav}
<div class="item">
<i class="users icon"></i> 使用者
<div class="menu">
<a class="item" href="/user" data-navigo>列表</a>
<a class="item" href="/user/add" data-navigo>新增</a>
</div>
</div>
<div class="item">
<i class="file text icon"></i> 文章
<div class="menu">
<a href="/post" class="item" data-navigo>列表</a>
</div>
</div>
<div class="item">
<i class="setting icon"></i> 系統
<div class="menu">
<a class="item" href="/" data-navigo>總覽</a>
<a class="item" href="/config" data-navigo>設定</a>
</div>
</div>
<div class="bottom menu">
<a href="../" class="item"><i class="arrow left icon"></i>返回部落格</a>
</div>
</div>
<div class="comupter squeezable pusher" id="pusher">
<div class="ts active inverted dimmer">
<div class="ts loader"></div>
</div>
<nav class="sidebar menu">
<button class="ts inverted icon button" id="toggleSidebar">
<i class="sidebar icon"></i>
</button>
</nav>
<div class="ts narrow container">
<div class="ts big dividing header" id="header"></div>
<div class="ts fluid container" id="content"></div>
</div>
</div>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script src="https://unpkg.com/navigo@6"></script>
<script src="../include/js/security.js"></script>
<script src="dashboard.js"></script>
{script}
</body>
</html>

238
ajax/comment.php Normal file
View File

@ -0,0 +1,238 @@
<?php
set_include_path('../include/');
$includepath = TRUE;
require_once('../connection/SQL.php');
require_once('../config.php');
require_once('security.php');
require_once('user.php');
require_once('article.php');
require_once('notification.php');
$user = validate_user();
if (!$user->valid) {
http_response_code(403);
header("Content-Type: applcation/json");
echo json_encode(array('status' => 'novalid'));
exit;
}
if (!isset($_GET['pid']) && !isset($_GET['del']) && !isset($_POST['pid']) && !isset($_POST['edit'])) {
send_error(404, "error");
} else {
if (isset($_GET['pid']) && trim($_GET['pid']) != "") {
if (isset($_SESSION['cavern_comment_time']) && $_SERVER['REQUEST_TIME'] - $_SESSION['cavern_comment_time'] > 10) {
// after 10 seconds
$_SESSION['cavern_comment_time'] = NULL;
unset($_SESSION['cavern_comment_time']);
}
$data = process_comments($_GET['pid']);
} else {
if (!$user->islogin) { // guest
send_error(401, "nologin");
}
if (!validate_csrf()) {
send_error(403, "csrf");
}
if (isset($_GET['del']) && trim($_GET['del']) != "") {
// delete comment
$result = cavern_query_result("SELECT * FROM `comment` WHERE `id`='%d'", array($_GET['del']));
if ($result['num_rows'] < 1) {
send_error(404, "error");
}
$author = $result['row']['username'];
if ($author !== $user->username) {
send_error(403, false);
}
$SQL->query("DELETE FROM `comment` WHERE `id`='%d' AND `username`='%s'", array($_GET['del'], $user->username));
$data = array(
"status" => TRUE,
"time" => round($_SERVER['REQUEST_TIME_FLOAT'] * 1000)
);
} else if (isset($_POST['content'])) {
if (isset($_POST['pid']) && trim($_POST['pid']) && isset($_SESSION['cavern_comment_time']) && $_SERVER['REQUEST_TIME'] - $_SESSION['cavern_comment_time'] < 10) {
// user can create one comment per 10 seconds
$remain_second = 10 - ($_SERVER['REQUEST_TIME'] - $_SESSION['cavern_comment_time']);
header('Retry-After: ' . $remain_second);
send_error(429, "ratelimit");
}
if ($user->muted) {
send_error(403, "muted");
}
if (trim($_POST['content']) != "") {
if (isset($_POST['pid']) && trim($_POST['pid']) != "") {
// new comment
try {
$article = new Article(intval($_POST['pid']));
} catch (NoPostException $e) {
send_error(404, "error");
}
http_response_code(201); // 201 Created
$time = date('Y-m-d H:i:s');
$SQL->query("INSERT INTO `comment` (`pid`, `username`, `time`, `content`) VALUES ('%d', '%s', '%s', '%s')", array($_POST['pid'], $user->username, $time, htmlspecialchars($_POST['content'])));
$comment_id = $SQL->insert_id();
$data = array(
"status" => TRUE,
"comment_id" => $comment_id,
"time" => round($_SERVER['REQUEST_TIME_FLOAT'] * 1000)
);
/* notification */
// notify tagged user
// the user who tag himself is unnecessary to notify
$username_list = parse_user_tag($_POST['content']);
foreach ($username_list as $key => $id) {
if ($id == $user->username) continue;
cavern_notify_user($id, "{{$user->name}}@{$user->username} 在 [{$article->title}] 的留言中提到了你", "post.php?pid={$article->pid}#comment-$comment_id", "comment");
}
// notify commenters
$commenters = cavern_query_result("SELECT `username` FROM `comment` WHERE `pid` = '%d'", array($_POST['pid']));
if ($commenters['num_rows'] > 0) {
do {
$u = $commenters['row']['username'];
if (!in_array($u, $username_list) && $u != $article->author && $u != $user->username) {
cavern_notify_user($u, "在你回應的文章 [{$article->title}] 中有了新的回應", "post.php?pid={$article->pid}#comment-$comment_id", "comment");
}
} while ($commenters['row'] = $commenters['query']->fetch_assoc());
}
// notify liked user
/* we won't inform the author for his like on his own post
and no notice for his own comment */
$likers = cavern_query_result("SELECT `username` FROM `like` WHERE `pid` = '%d'", array($_POST['pid']));
if ($likers['num_rows'] > 0) {
do {
$u = $likers['row']['username'];
if (!in_array($u, $username_list) && $u != $article->author && $u != $user->username) {
cavern_notify_user($u, "在你喜歡的文章 [{$article->title}] 中有了新的回應", "post.php?pid={$article->pid}#comment-$comment_id", "comment");
}
} while ($likers['row'] = $likers['query']->fetch_assoc());
}
// notify post author
/* we won't inform the author if he has been notified for being tagged
also, we won't notify the author for his own comment */
if (!in_array($article->author, $username_list) && $article->author != $user->username) {
cavern_notify_user($article->author, "{{$user->name}}@{$user->username} 回應了 [{$article->title}]", "post.php?pid={$article->pid}#comment-$comment_id", "comment");
}
// only new comment should be limited
$_SESSION['cavern_comment_time'] = $_SERVER['REQUEST_TIME'];
} else if (isset($_POST['edit']) && trim($_POST['edit']) != "") {
// edit comment
$query = cavern_query_result("SELECT * FROM `comment` WHERE `id` = '%d'", array($_POST['edit']));
if ($query['num_rows'] < 1) {
send_error(404, "error");
}
if ($query['row']['username'] !== $user->username) {
send_error(403, "author");
}
$time = date('Y-m-d H:i:s', $_SERVER['REQUEST_TIME']);
$SQL->query("UPDATE `comment` SET `content`='%s', `modified`='%s' WHERE `id`='%d' AND `username`='%s'", array(htmlspecialchars($_POST['content']), $time, $_POST['edit'], $user->username));
$data = array(
"status" => TRUE,
"comment_id" => $_POST['edit'],
"time" => round($_SERVER['REQUEST_TIME_FLOAT'] * 1000)
);
} else {
send_error(400, "empty");
}
} else {
send_error(400, "empty");
}
}
}
}
header('Content-Type: application/json');
echo json_encode($data);
exit;
function process_comments($pid) {
if (isset($_SESSION["cavern_username"])) {
$user = new User($_SESSION["cavern_username"]);
} else {
$user = new User(""); // guest
}
if (cavern_query_result("SELECT * FROM `post` WHERE `pid`=%d", array($pid))['num_rows'] < 1) {
http_response_code(404);
$json = array('status' => 'error', 'fetch' => round($_SERVER['REQUEST_TIME_FLOAT'] * 1000)); // to fit javascript unit
return $json;
}
if (isset($_COOKIE['cavern_commentLastFetch'])) {
$last_fetch_time = $_COOKIE['cavern_commentLastFetch'];
}
$email_hash = array();
$names = array();
$id_list = array();
$modified = array();
$comments = array();
$result = cavern_query_result("SELECT * FROM `comment` WHERE `pid`='%d'", array($pid));
$json = array('status' => TRUE, 'fetch' => round($_SERVER['REQUEST_TIME_FLOAT'] * 1000)); // to fit javascript unit
if ($result['num_rows'] > 0) {
do {
$username = $result['row']['username'];
if (!isset($names[$username])) {
$target_user = new User($username);
$name = $target_user->name;
$email = $target_user->email;
$names[$username] = $name;
$email_hash[$username] = md5(strtolower($email));
}
$comment = array(
"id" => $result['row']['id'],
"username" => $username,
"markdown" => $result['row']['content'],
"time" => $result['row']['time'],
"modified" => (is_null($result['row']['modified']) ? FALSE : $result['row']['modified'])
// if the comment has been modified, set this value as modified time; otherwise, set to FALSE
);
if ($user->islogin && $user->username === $username) {
$comment['actions'] = array("reply", "edit", "del");
} else if ($user->islogin) {
$comment['actions'] = array("reply");
} else {
$comment['actions'] = array();
}
$id_list[] = $comment['id']; // append id
$comments[] = $comment; // append comment
if (!is_null($result['row']['modified']) && isset($last_fetch_time)) {
if (strtotime($result['row']['modified']) - $last_fetch_time > 0) {
$modified[] = $comment["id"];
}
}
} while ($result['row'] = $result['query']->fetch_assoc());
}
$json['idList'] = $id_list;
$json['modified'] = $modified;
$json['comments'] = $comments;
$json['names'] = $names;
$json['hash'] = $email_hash;
return $json;
}
function send_error($code, $message) {
http_response_code($code);
header('Content-Type: application/json');
echo json_encode(array('status' => $message, 'fetch' => round($_SERVER['REQUEST_TIME_FLOAT'] * 1000))); // to fit javascript timestamp
exit;
}
?>

109
ajax/like.php Normal file
View File

@ -0,0 +1,109 @@
<?php
set_include_path('../include/');
$includepath = TRUE;
require_once('../connection/SQL.php');
require_once('../config.php');
require_once('user.php');
require_once('security.php');
require_once('notification.php');
$user = validate_user();
if (!$user->valid) {
http_response_code(403);
header("Content-Type: applcation/json");
echo json_encode(array('status' => 'novalid'));
exit;
}
if (!isset($_GET['pid'])) {
http_response_code(404);
$data = array('status' => 'error');
} else {
$pid = $_GET['pid'];
$article = cavern_query_result("SELECT * FROM `post` WHERE `pid`='%d'", array($pid));
if ($article['num_rows'] < 1) {
http_response_code(404);
echo json_encode(array('status' => 'nopost', 'id' => $pid));
exit;
}
$likes_query = process_like($pid, $user);
$islike = $likes_query[0];
$likes = $likes_query[1];
$likers = $likes_query[2];
if (isset($_GET['fetch'])) {
// fetch likes
$data = array('status' => 'fetch', 'id' => $pid, 'likes' => $likes, 'likers' => $likers);
header('Content-Type: application/json');
echo json_encode($data);
exit;
} else if (!$user->islogin) {
// ask guest to login
$data = array('status' => 'nologin', 'id' => $pid, 'likes' => $likes, 'likers' => $likers);
http_response_code(401);
header('Content-Type: application/json');
echo json_encode($data);
exit;
} else {
// user like actions
if (!validate_csrf()) { // csrf attack!
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(array('status' => 'csrf', 'id' => $pid, 'likes' => $likes, 'likers' => $likers));
exit;
}
if ($islike) {
// unlike
$SQL->query("DELETE FROM `like` WHERE `pid`='%d' AND `username`='%s'", array($pid, $user->username));
$result = process_like($pid, $user);
$likes = $result[1];
$likers = $result[2];
$data = array('status' => FALSE, 'id' => $pid, 'likes' => $likes, 'likers' => $likers);
} else {
// like
$SQL->query("INSERT INTO `like` (`pid`, `username`) VALUES ('%d', '%s')", array($pid, $user->username));
$result = process_like($pid, $user);
$likes = $result[1];
$likers = $result[2];
$data = array('status' => TRUE, 'id' => $pid, 'likes' => $likes, 'likers' => $likers);
/* notification */
// notify article author
// we should notify author this only once
$author = $article['row']['username'];
$notification_query = cavern_query_result("SELECT * FROM `notification` WHERE `username`='%s' AND `url`='%s' AND `type`='%s'", array($author, "post.php?pid=$pid", "like"));
if (!($notification_query['num_rows'] > 0) && $user->username !== $author) {
cavern_notify_user($author, "{{$user->name}}@{$user->username} 推了 [{$article['row']['title']}]", "post.php?pid=$pid", "like");
}
}
}
}
function process_like($pid, $user) {
$islike = false;
$likers = array();
$likes_query = cavern_query_result("SELECT * FROM `like` WHERE `pid`='%d'", array($pid));
if ($likes_query['num_rows'] < 1){
$likes = 0;
} else {
$likes = $likes_query['num_rows'];
do {
$likers[] = $likes_query['row']['username'];
if ($user->username === $likes_query['row']['username']) {
$islike = true;
}
} while ($likes_query['row'] = $likes_query['query']->fetch_assoc());
}
return array($islike, $likes, array_unique($likers));
}
header('Content-Type: application/json');
echo json_encode($data);
exit;
?>

51
ajax/notification.php Normal file
View File

@ -0,0 +1,51 @@
<?php
set_include_path('../include/');
$includepath = TRUE;
require_once('../include/security.php');
require_once('../connection/SQL.php');
require_once('../config.php');
if (isset($_GET['fetch']) || isset($_GET['count'])) {
if (isset($_SESSION['cavern_username'])) {
if (isset($_GET['fetch'])) {
$data = process_notifications(20); // fetch 20 comments
$SQL->query("UPDATE `notification` SET `read` = 1 WHERE `read` = 0 AND `username` = '%s'", array($_SESSION['cavern_username'])); // read all comments
} else if (isset($_GET['count'])) {
$query = cavern_query_result("SELECT COUNT(*) AS `count` FROM `notification` WHERE `username` = '%s' AND `read` = 0", array($_SESSION['cavern_username']));
$count = $query['row']['count'];
$data = array("status" => TRUE, "fetch" => round($_SERVER['REQUEST_TIME_FLOAT'] * 1000), "unread_count" => $count);
}
} else {
send_error(401, "nologin");
}
} else {
send_error(404, "error");
}
header('Content-Type: application/json');
echo json_encode($data);
exit;
function process_notifications($limit) {
$result = cavern_query_result("SELECT * FROM `notification` WHERE `username` = '%s' ORDER BY `time` DESC LIMIT %d" ,array($_SESSION['cavern_username'], $limit));
$json = array('status' => TRUE, 'fetch' => round($_SERVER['REQUEST_TIME_FLOAT'] * 1000)); // to fit javascript unit
$feeds = array();
if ($result['num_rows'] > 0) {
do {
$feeds[] = $result['row'];
} while ($result['row'] = $result['query']->fetch_assoc());
}
$json['feeds'] = $feeds;
return $json;
}
function send_error($code, $message) {
http_response_code($code);
header('Content-Type: application/json');
echo json_encode(array('status' => $message, 'fetch' => round($_SERVER['REQUEST_TIME_FLOAT'] * 1000))); // to fit javascript timestamp
exit;
}
?>

107
ajax/posts.php Normal file
View File

@ -0,0 +1,107 @@
<?php
set_include_path('../include/');
$includepath = TRUE;
require_once('../connection/SQL.php');
require_once('../config.php');
require_once('user.php');
require_once('article.php');
$data = array("fetch" => round($_SERVER['REQUEST_TIME_FLOAT'] * 1000));
$user = validate_user();
if (!$user->valid) {
$data["status"] = "invalid";
http_response_code(403);
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
if (isset($_GET['pid'])) {
// get data of single post
$pid = abs($_GET['pid']);
try {
$article = new Article($pid);
} catch (NoPostException $e) {
// post not found
http_response_code(404);
$data['message'] = $e->getMessage();
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
$post = array(
'author' => $article->author,
'name' => $article->name,
'title' => $article->title,
'content' => $article->content,
'time' => $article->time,
'likes_count' => $article->likes_count,
'comments_count' => $article->comments_count,
'islike' => $article->is_like($user)
);
$data['post'] = $post;
} else {
// get posts list
if (isset($_GET['limit']) && trim($_GET['limit']) != ""){
$limit = intval($_GET['limit']);
} else {
$limit = intval($blog['limit']);
}
if (isset($_GET['page']) && trim($_GET['page']) != "") {
$page = intval($_GET['page']);
$limit_start = abs(($page - 1) * $limit);
} else if (isset($_GET['username']) && trim($_GET['username']) != "") {
$mode = "username";
} else {
$page = 1;
$limit_start = 0;
}
if (isset($mode) && $mode == "username") {
$post_list = article_list(cavern_query_result(
"SELECT `post`.*, `user`.name FROM `post` INNER JOIN `user` ON `post`.username = `user`.username WHERE `post`.username = '%s' ORDER BY `time`",
array($_GET['username'])
));
$all_posts_count = cavern_query_result("SELECT COUNT(*) AS `count` FROM `post` WHERE `username` = '%s'", array($_GET['username']))['row']['count'];
} else {
$post_list = article_list(cavern_query_result(
"SELECT `post`.*, `user`.name FROM `post` INNER JOIN `user` ON `post`.username = `user`.username ORDER BY `time` DESC LIMIT %d,%d",
array($limit_start, $limit)
));
$all_posts_count = cavern_query_result("SELECT COUNT(*) AS `count` FROM `post`")['row']['count'];
$data['page_limit'] = $limit;
$data['page'] = $page;
}
$data['all_posts_count'] = intval($all_posts_count);
$posts = array();
foreach ($post_list as $_key => $article) {
$post = array(
'username' => $article->author,
'name' => $article->name,
'pid' => $article->pid,
'title' => $article->title,
'content' => $article->content,
'time' => $article->time,
'likes_count' => $article->likes_count,
'comments_count' => $article->comments_count,
'islike' => $article->is_like($user)
);
$posts[] = $post; // append post
}
$data["posts"] = $posts;
}
header('Content-Type: application/json');
echo json_encode($data);
exit;
?>

60
ajax/user.php Normal file
View File

@ -0,0 +1,60 @@
<?php
set_include_path('../include/');
$includepath = TRUE;
require_once('../connection/SQL.php');
require_once('../config.php');
require_once('user.php');
$user = validate_user();
if (!$user->valid) {
send_error(403, "invalid", $user->islogin);
}
if (isset($_GET['username']) && trim($_GET['username']) != "") {
// query other user's profile
$username = trim($_GET['username']);
} else if ($user->islogin) {
// query the profile of the user himself
$username = $user->username;
} else {
// username isn't provided
send_error(404, "error");
}
try {
$target_user = new User($username);
} catch (NoUserException $_e) {
send_error(404, "nouser", $user->islogin);
}
$posts = cavern_query_result("SELECT * FROM `post` WHERE `username`='%s'", array($username));
$posts_count = ($posts['num_rows'] > 0 ? $posts['num_rows'] : 0);
$data = array(
"username" => $target_user->username,
"name" => $target_user->name,
"level" => $target_user->level,
"role" => cavern_level_to_role($target_user->level),
"hash" => md5(strtolower($target_user->email)),
"muted" => $target_user->muted,
"posts_count" => $posts_count
);
// user himself and admin can see user's email
if ($user->username === $target_user->username || $user->level >= 8) {
$data["email"] = $target_user->email;
}
$data["login"] = $user->islogin;
$data["fetch"] = round($_SERVER['REQUEST_TIME_FLOAT'] * 1000); // fit javascript timestamp
header('Content-Type: application/json');
echo json_encode($data);
exit;
function send_error($code, $message, $islogin) {
http_response_code($code);
header('Content-Type: application/json');
echo json_encode(array("status" => $message, "login" => $islogin, "fetch" => round($_SERVER['REQUEST_TIME_FLOAT'] * 1000))); // to fit javascript timestamp
exit;
}

14
config.php Normal file
View File

@ -0,0 +1,14 @@
<?php
require_once('include/function.php');
if(!session_id()) {
session_start();
}
global $blog;
date_default_timezone_set("Asia/Taipei");
$blog['name'] = 'Cavern'; //網站名稱
$blog['limit'] = 10; //首頁顯示文章數量
$blog['register'] = true; //是否允許註冊
?>

14
config.template Normal file
View File

@ -0,0 +1,14 @@
<?php
require_once('include/function.php');
if(!session_id()) {
session_start();
}
global $blog;
date_default_timezone_set("Asia/Taipei");
$blog['name'] = '{blog_name}'; //網站名稱
$blog['limit'] = {limit}; //首頁顯示文章數量
$blog['register'] = {register}; //是否允許註冊
?>

16
connection/SQL.php Normal file
View File

@ -0,0 +1,16 @@
<?php
if(!@$includepath){
set_include_path('include/');
}
error_reporting(E_ALL);
require_once('db.php');
$database_SQL = ""; // 資料庫名稱
$username_SQL = ""; // 連線帳號
$password_SQL = ""; // 連線密碼
$hostname_SQL = ""; // MySQL伺服器
global $SQL;
$SQL = new Database($hostname_SQL,$username_SQL,$password_SQL,$database_SQL);
$SQL->query("SET NAMES 'utf8mb4'");

80
include/article.php Normal file
View File

@ -0,0 +1,80 @@
<?php
class NoPostException extends Exception {}
class Article {
private $pid;
private $author;
private $name;
private $title;
private $content;
private $time;
private $likes_count;
private $comments_count;
private $islike = false;
public function __construct($data) {
if (is_int($data)) {
$this->pid = $data;
$query = cavern_query_result("SELECT `post`.*, `user`.name FROM `post` INNER JOIN `user` ON `post`.username = `user`.username WHERE `pid`=%d", array($this->pid));
if ($query['num_rows'] > 0) {
$result = $query['row'];
} else {
// post doesn't exist
throw new NoPostException('There is no post with pid '.$this->pid);
}
} else if (is_array($data)) {
/* pass the sql result directly */
$result = $data;
$this->pid = $result['pid'];
}
if (isset($result['name'])) $this->name = $result['name']; else $this->name = "";
$this->author = $result['username'];
$this->title = $result['title'];
$this->content = $result['content'];
$this->time = $result['time'];
$this->likes_count = $result['like'];
$this->comments_count = $result['comment'];
}
public function __get($name) {
return $this->$name;
}
public function is_like(User $user) {
if ($this->likes_count > 0 && $user->islogin) {
$like_query = cavern_query_result("SELECT * FROM `like` WHERE `pid`='%d' AND `username`='%s'", array($this->pid, $_SESSION['cavern_username']));
if ($like_query['num_rows'] > 0) {
$this->islike = true;
}
}
return $this->islike;
}
public function modify(User $user, $name, $value) {
// article author and admin can edit post
if ($user->islogin && ($user->username === $this->author || $user->level >= 8)) {
$this->$name = $value;
} else {
return false;
}
}
public function save() {
global $SQL;
$SQL->query("UPDATE `post` SET `title`='%s', `content`='%s' WHERE `pid`='%d' AND `username`='%s'", array(htmlspecialchars($_POST['title']), htmlspecialchars($_POST['content']), $this->pid, $this->author));
}
}
function article_list($query_result) {
$article_list = array();
if ($query_result['num_rows'] > 0) {
do {
$article_list[] = new Article($query_result['row']);
} while ($query_result['row'] = $query_result['query']->fetch_assoc());
}
return $article_list;
}

440
include/css/cavern.css Normal file
View File

@ -0,0 +1,440 @@
/* General style */
* {
letter-spacing: .02em;
}
img {
max-width: 100%;
object-fit: contain;
}
div.table.wrapper {
overflow-x: auto;
width: 100%;
}
div.table.wrapper:not(:last-child) {
margin-bottom: .75em;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
border: none;
margin-top: 0 !important;
padding-bottom: 0;
}
.markdown-body h1:not(.ts):not(.unstyled) {
font-size: 1.6em;
}
.markdown-body h2:not(.ts):not(.unstyled) {
font-size: 1.5em;
}
.markdown-body h3:not(.ts):not(.unstyled) {
font-size: 1.4em;
}
.markdown-body h4:not(.ts):not(.unstyled) {
font-size: 1.3em;
}
.markdown-body h5:not(.ts):not(.unstyled) {
font-size: 1.2em;
}
.markdown-body h6:not(.ts):not(.unstyled) {
font-size: 1.1em;
}
.markdown-body > .markdown + * { /* .markdown is invisible and the first element in a post, so neighbor of .markdown is the first visible element */
margin-top: 0 !important;
}
.markdown-body > *:last-child:not(div) {
margin-bottom: 0 !important;
}
.editormd-html-preview {
background-color: inherit;
}
.editormd-html-preview code { /* inline code */
font-size: 12px;
}
.editormd-html-preview a {
word-break: break-all;
}
.editormd-html-preview img {
margin: .4em 0;
}
img.emoji {
vertical-align: middle;
}
/* fonts */
/* prevent this rule from overwriting the style of <span> of KaTeX */
.markdown-body *:not(span), .markdown-body > :not(.editormd-tex) span,
/* editor font */
[class*="CodeMirror"] *, [class*="cm"] * {
font-family: Consolas,"SF Pro TC","SF Pro Text","SF Pro Icons","PingFang TC","Helvetica Neue","Helvetica","Microsoft JhengHei","Segoe UI",Ubuntu,微軟正黑體,"LiHei Pro","Droid Sans Fallback",Roboto,"Helvetica Neue","Droid Sans","Arial",sans-serif;
}
.katex * { /* hack */
font-family: KaTeX_Main, Times New Roman, serif;
}
/* sweet alert */
.swal2-popup h2.swal2-title {
margin: 0 0 .4em;
}
/* menu */
#menu button.login.button {
margin: 5px;
}
/* notification */
#menu .notification.icon.item i.icon {
margin-right: 0;
}
#menu .notification.icon.item span.counter {
display: block;
padding: .1em .2em;
font-size: 12px;
color: white;
background-color: #F03434;
border-radius: .2em;
position: absolute;
top: .25em;
right: .25em;
}
.notification.container {
display: none;
position: absolute;
z-index: 11; /* to overlap editormd */
top: 1em;
right: .2em;
width: calc(100vw - 2.16em);
max-width: 400px;
height: 85vh;
max-height: 500px;
background-color: white;
border-radius: .28571rem;
box-shadow: 0 0 3px 0 #888888;
}
.active.notification.container {
display: flex;
flex-direction: column;
}
.notification.container > .ts.segment:first-child {
background-color: #EEE;
}
.notification.container .ts.feed {
overflow-y: auto;
flex-grow: 1;
margin: 0;
}
.notification.container .ts.feed .event {
padding-left: .8em;
padding-right: .8em;
}
.notification.container .ts.feed .unread.event {
background-color: #e4f2f5;
}
.notification.container .ts.feed .event:hover {
background-color: #e2edef;
}
.notification.click.handler {
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: 0;
z-index: 1;
}
.notification.container .ts.fluid.bottom:last-child {
flex-shrink: 0;
}
.ts.dividing.header .notification.description {
font-size: .6em;
color: gray;
margin-left: .5em;
}
/* main */
#main {
padding: 10px 0 20px;
}
/* content */
#content {
min-height: 50vh;
}
/* pages */
.loading#cards ~ #pages {
display: none;
}
/* cards */
#cards {
padding: 1em 0;
}
.loading#cards {
display: none;
}
.ts.card > .content > .header:not(.ts) {
font-size: 1.65em;
}
.ts.card > .content > .description.markdown-body {
background: transparent;
font-size: inherit;
padding: 0;
}
.ts.card > .content > .description.markdown-body a {
word-break: break-all;
}
/* post */
#content .ts.grid > #header > .ts.header {
/* align this with post content */
padding-left: 15px;
}
#content .ts.grid > .action.column {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
#content > .ts.segments:not(:last-child) {
margin-bottom: 20px;
}
#post {
font-size: 15px;
margin-top: 1em;
}
.markdown-body ul:first-child, .markdown-body ol:first-child {
margin-top: 0;
}
.markdown-body#post h1:not(.ts) {
font-size: 2em;
}
.markdown-body#post h2:not(.ts) {
font-size: 1.85em;
}
.markdown-body#post h3:not(.ts) {
font-size: 1.7em;
}
.markdown-body#post h4:not(.ts) {
font-size: 1.55em;
}
.markdown-body#post h5:not(.ts) {
font-size: 1.4em;
}
.markdown-body#post h6:not(.ts) {
font-size: 1.25em;
}
#toc {
min-height: 8em;
max-height: calc(95vh - 3em);
overflow-x: auto;
}
/* code block */
pre.prettyprint ol.linenums:not(.ts) {
counter-reset: code 0;
margin-left: 0;
}
pre.prettyprint ol.linenums:not(.ts) > li::before {
counter-increment: code;
content: counter(code);
/* line numbers align */
right: 100%;
margin-left: 0;
padding-right: .5em;
}
pre.prettyprint ol.linenums:not(.ts) > li > code {
min-height: 1em; /* fixing collapsed empty line */
}
pre.prettyprint ol.linenums:not(.ts) > li > code > span {
font-family: "YaHei Consolas Hybrid", 'Consolas', "Meiryo UI", "Malgun Gothic", "Segoe UI", "Trebuchet MS", Helvetica, monospace;
}
/* post editor */
#edit .action.column {
display: flex;
justify-content: center;
align-items: center;
}
#markdownEditor:not(.editormd-fullscreen) {
margin-top: 1em;
}
.editormd-fullscreen {
z-index: 10;
}
/* comments */
.ts.comments {
min-height: 6em;
max-width: 100%;
}
.ts.comments .comment {
padding: 0.25em 0 0.25em;
margin: 0.25em 0 0.25em;
}
.ts.no-comment.segment:not(.active), .ts.active.loader + .fetch.button {
display: none;
}
.comment.header {
width: 100%;
position: sticky;
position: -webkit-sticky;
padding-top: .8em;
top: 0;
background-color: white;
z-index: 2;
}
.ts.comment.divider {
margin-top: .5em;
}
.stretched.header.column {
display: flex;
justify-content: center;
flex-direction: column;
}
.comment .markdown-body {
padding: .2em 0;
}
.comment .markdown-body img {
margin: .4em 0;
}
.comment img.emoji {
height: 16px;
width: 16px;
}
.emphasized.comment {
animation: commentEmphasize 2s ease-in .1s;
}
/* comment editor */
#comment > .ts.segment:first-child {
padding-top: 1em;
}
#comment > .ts.segment:first-child > .ts.tabbed.menu {
padding: 0 1em;
}
#comment > .ts.segment:not(:first-child) {
border-top: none;
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
border-bottom: 1px solid #e9e9e9;
}
#comment .ts.button {
margin-top: 1em;
}
#preview {
min-height: 15em;
}
/* account */
.ts.label.avatar.tooltip {
border: 0;
border-radius: .21429rem;
}
.ts.form .disabled.field {
cursor: not-allowed;
pointer-events: initial;
}
.ts.form .disabled.field input {
pointer-events: none;
}
/* profile */
#avatar {
width: 7.5em;
}
/* sidebar */
#sidebar .ts.header .avatar.image {
margin-right: 5px;
}
#sidebar .ts.header .negative.sub.header {
color: #CE5F58;
}
/* footer */
footer {
padding-bottom: 15px;
}
footer .ts.divider {
width: 80%;
margin: auto;
}
@keyframes commentEmphasize {
from {
background-color: #e4f2f5;
}
to {
background-color: unset;
}
}

84
include/db.php Normal file
View File

@ -0,0 +1,84 @@
<?php
/* Cavern Edition
modified by t510599 at 2019/05/30
*/
/*
<Secret Blog>
Copyright (C) 2012-2017 太陽部落格站長 Secret <http://gdsecret.com>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, version 3.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<http://www.gnu.org/licenses/>.
*/
class Database {
private $conn;
private $addr;
private $user;
private $pass;
private $db;
public function __construct($addr,$user,$pass,$db){
$this->addr = $addr;
$this->user = $user;
$this->pass = $pass;
$this->db = $db;
$this->conn = new mysqli($addr,$user,$pass,$db);
if($this->conn->connect_error !== null){
throw new Exception($this->conn->connect_error);
}
}
private function reconnect(){
$this->conn = new mysqli($this->addr,$this->user,$this->pass,$this->db);
}
private function checkConn(){
return $this->conn->ping();
}
public function query($query,$data = array()){
if(!$this->checkConn()) $this->reconnect();
foreach($data as $k=>$d){
$data[$k] = $this->conn->real_escape_string($d);
}
$result = $this->conn->query(vsprintf($query,$data));
if($result === false){
throw new Exception($this->conn->error);
}
return $result;
}
public function insert_id() {
return $this->conn->insert_id;
}
};

117
include/function.php Normal file
View File

@ -0,0 +1,117 @@
<?php
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
function cavern_login($username, $password) {
global $SQL;
if (isset($username) && isset($password)) {
$login = $SQL->query("SELECT `username`, `pwd` FROM `user` WHERE `username` = '%s' AND `pwd` = '%s'",array($username, cavern_password_hash($password, $username)));
if ($login->num_rows > 0) {
$_SESSION['cavern_username'] = $username;
return 1;
}
else {
return -1;
}
} else {
return -1;
}
}
function cavern_logout() {
$_SESSION['cavern_username'] = NULL;
unset($_SESSION['cavern_username']);
return 1;
}
function cavern_password_hash($value, $salt) {
$temp = substr(sha1(strrev($value).$salt), 0, 24);
return hash('sha512', $temp.$value);
}
function cavern_query_result($query, $data=array()) {
global $SQL;
$result['query'] = $SQL->query($query, $data);
$result['row'] = $result['query']->fetch_assoc();
$result['num_rows'] = $result['query']->num_rows;
if ($result['num_rows'] > 0) {
return $result;
} else {
return -1;
}
}
function cavern_level_to_role($level) {
switch ($level) {
case 9:
$role = "站長";
break;
case 8:
$role = "管理員";
break;
case 1:
$role = "作者";
break;
case 0:
$role = "會員";
break;
default:
$role = "麥克雞塊";
break;
}
return $role;
}
function cavern_greeting() {
$hour = date('G');
if ($hour >= 21 || $hour < 5) {
$greeting = "晚安";
} else if ($hour >= 12) {
$greeting = "午安";
} else if ($hour >= 5 && $hour < 12) {
$greeting = "早安";
}
return $greeting;
}
function cavern_pages($now_page, $total, $limit) {
$text='<div class="ts basic center aligned segment" id="pages">';
$text.='<select class="ts basic dropdown" onchange="location.href=this.options[this.selectedIndex].value;">';
$now_page = abs($now_page);
$page_num = ceil($total / $limit);
for ($i = 1; $i <= $page_num; $i++) {
if ($now_page != $i) {
$text.='<option value="index.php?page='.$i.'">第 '.$i.' 頁</option>';
} else {
$text.='<option value="index.php?page='.$i.'" selected="selected">第 '.$i.' 頁</option>';
}
}
$text.='</select>';
$text.='</div>';
return $text;
}
function sumarize($string, $limit) {
$count = 0;
$text = "";
$content_start = FALSE;
foreach (explode("\n", $string) as $line) {
if (trim($line) != "" && $content_start == FALSE) {
$content_start = TRUE; // don't count the empty line until the main content
}
if (!$content_start) {
continue;
}
$count++;
$text.=$line."\n";
if ($count == $limit || mb_strlen($text) >= 200) {
if (mb_strlen($text) >= 200) {
$text = mb_substr($text, 0, 200)."...\n";
}
$text.="...(還有更多)\n";
break;
}
}
return $text;
}

102
include/js/account.js Normal file
View File

@ -0,0 +1,102 @@
if (document.newacc) {
// Register
let form = document.newacc;
eventListenerInitialize(form, [form.password, form.repeat]);
$(form.username).on('change', function () {
let self = this;
if (!/^[a-z][a-z0-9_-]*$/.test(self.value) || (self.value.length > 20 || self.value == "")) {
setFieldStatus(self, "error", "請依照格式輸入");
setFieldLabel(self, "");
} else {
setFieldStatus(self, ""); // reset
setFieldLabel(self, "");
axios.request({
method: "GET",
url: `ajax/user.php?username=${this.value}`,
responseType: "json"
}).then(function (_res) {
// username exist
setFieldStatus(self, "error", "此帳號已被使用");
setFieldLabel(self, "此帳號已被使用");
}).catch(function (_error) {
// username not exist
setFieldStatus(self, "success");
setFieldLabel(self, "此帳號可以使用");
});
}
});
} else if (document.editacc) {
// Manage Profile
let form = document.editacc;
eventListenerInitialize(form, [form.new, form.repeat]);
$(form.new).on('input', function () {
if (this.value == "") {
form.repeat.removeAttribute("required");
setFieldStatus(form.repeat, "", "", false);
} else {
form.repeat.setAttribute("required", "required");
}
});
}
function eventListenerInitialize (form, inputs) {
// first is password input, second is repeat input
inputs.forEach(function (el) {
$(el).on('input', function (_e) {
if (inputs[0].value == inputs[1].value && inputs[0].value != "") {
setFieldStatus(inputs[1], "success");
} else {
setFieldStatus(inputs[1], "error", "密碼不正確,請再試一次。");
}
});
});
$(form).on('submit', function (e) {
e.preventDefault();
if (inputs[0].value != inputs[1].value) {
inputs[1].setCustomValidity("密碼不正確,請再試一次。");
inputs[1].focus();
return undefined;
}
let fd = new URLSearchParams(new FormData(this)).toString();
axios.request({
method: "POST",
data: fd,
url: "account.php",
headers: {
'Content-Type': "application/x-www-form-urlencoded"
}
}).then(function (res) {
location.href = res.headers["axios-location"];
}).catch(function (error) {
if (error.response) {
location.href = error.response.headers["axios-location"];
} else {
ts('.snackbar').snackbar({
content: "發送失敗。"
});
}
});
});
}
function setFieldStatus(el, status, validity="", required=true) {
el.parentElement.className = (required) ? `${status} required field` : `${status} field`;
el.setCustomValidity(validity);
}
function setFieldLabel(el, text) {
let sibling = el.nextElementSibling;
if (sibling.tagName == "SMALL" && text != "") {
let span = document.createElement('span');
span.className = "message";
span.innerText = text;
el.parentElement.insertBefore(span, sibling);
} else if (sibling.tagName == "SPAN" && text == "") {
$(sibling).remove();
} else if (sibling.tagName != "SMALL") {
sibling.innerText = text;
}
}

28
include/js/cards.js Normal file
View File

@ -0,0 +1,28 @@
const cdnjs = "https://cdnjs.cloudflare.com/ajax/libs";
// Load Libraries
const libraries = [
cdnjs + "/marked/0.5.1/marked.min.js",
cdnjs + "/prettify/r298/prettify.min.js",
cdnjs + "/underscore.js/1.9.1/underscore-min.js"
];
loadJS(libraries).then(function () {
editormd.$marked = marked;
editormd.loadFiles.js.push(...libraries.map(url => url.slice(0, -3))); // remove ".js"
document.querySelectorAll('.ts.card .description').forEach(function(el) {
let id = el.getAttribute('id');
parseMarkdown(id, el.children[0].textContent, {
toc: false,
flowChart: false,
sequenceDiagram: false,
htmlDecode : "script,iframe,style|on*"
}).children('.markdown').hide();
});
postProcess();
setTimeout(function () {
// show cards
$('.loading#cards').removeClass('loading');
$('#content .active.loader').removeClass('active');
}, 500);
});

328
include/js/comment.js Normal file
View File

@ -0,0 +1,328 @@
const pid = $('#post').attr('data-id');
var post = { // post cache
fetchTime: undefined,
comments: [],
idList: [] // ids of comment
}
// Fetch
$('.fetch.button').click(function(_e) {
fetchComments();
});
var fetchTimer = setInterval(fetchComments, 5*60*1000); // polling comments per 5 minutes
function fetchComments() {
$('.ts.inline.loader').addClass('active');
if (!pid) {
console.error('An error occurred while fetching comments.');
snackbar("無法載入留言。");
return undefined;
}
if (post.fetchTime) document.cookie = `cavern_commentLastFetch=${post.fetchTime}; Max-Age=10`;
axios.request({
method: "GET",
url: `ajax/comment.php?pid=${pid}`,
responseType: "json"
}).then(function (res) {
let data = res.data;
let t = new Date(data.fetch);
post.fetchTime = Math.ceil(data.fetch / 1000); // php timestamp
$('span.fetch.time').text(`Last fetch: ${t.getHours()}:${ t.getMinutes() < 10 ? '0' + t.getMinutes() : t.getMinutes() }`);
parseComments(data);
}).catch(function (error) {
if (error.response) {
let res = error.response;
console.error(`An error occurred while fetching comments of pid ${pid}, status ${res.status}`);
} else {
console.error(`An error occurred while fetching comments of pid ${pid}`);
}
snackbar("無法載入留言。");
});
setTimeout(() => { $('.ts.inline.loader').removeClass('active'); }, 250);
}
function parseComments(data) {
const commentTemplate = `<div class="comment" id="comment-{{ id }}" data-comment="{{ id }}"><a class="avatar" href="user.php?username={{ username }}"><img src="https://www.gravatar.com/avatar/{{ hash }}?d=https%3A%2F%2Ftocas-ui.com%2Fassets%2Fimg%2F5e5e3a6.png"></a><div class="content"><a class="author" href="user.php?username={{ username }}">{{ name }}</a><div class="middoted metadata"><div class="time">{{ time }}</div></div><div class="text" id="markdown-comment-{{ id }}"></div></div></div>`;
let add = data.idList.filter(function(item) { return post.idList.indexOf(item) < 0 }); // id list of new comments
let remove = post.idList.filter(function(item) { return data.idList.indexOf(item) < 0 }); // id list of removed comments
for (postId of remove) {
$(`.ts.comments div[data-comment="${postId}"]`).remove();
}
for (c of data.comments) {
if (add.indexOf(c.id) == -1 && data.modified.indexOf(c.id) == -1) {
continue;
}
if (add.indexOf(c.id) != -1) {
// render new comment
let node = commentTemplate.replace(/{{ id }}/gm, c.id).replace('{{ time }}', c.time).replace(/{{ username }}/gm, c.username).replace('{{ name }}', data.names[c.username]).replace('{{ hash }}', data.hash[c.username]);
$(node).appendTo('.ts.comments');
if (c.actions.length != 0) {
let actions = document.createElement('div');
actions.className = "actions";
$(`div[data-comment="${c.id}"] .content`).append(actions);
for (act of c.actions) {
switch (act) {
case "reply":
actions.insertAdjacentHTML('beforeend',`<a class="reply" data-username="${c.username}">回覆</a>`);
break;
case "del":
actions.insertAdjacentHTML('beforeend',`<a class="delete" data-comment="${c.id}">刪除</a>`);
break;
case "edit":
actions.insertAdjacentHTML('beforeend',`<a class="edit" data-comment="${c.id}">編輯</a>`);
break;
default:
break;
}
}
}
} else if (data.modified.indexOf(c.id) != -1) {
// empty the old content
$(`#markdown-comment-${c.id}`).html('');
}
if (c.modified) {
let $metadata = $(`div[data-comment="${c.id}"] .metadata`);
if ($metadata.children('.modified').length) {
$metadata.children('.modified').attr('title', c.modified);
} else {
$metadata.append(`<div class="modified" title="${c.modified}">已編輯</div>`);
}
}
parseMarkdown(`markdown-comment-${c.id}`, _.unescape(c.markdown), {
toc: false
});
}
post.comments = data.comments; // cache data
post.idList = data.idList; // cache data
postProcess();
/* jump to the comment and emphasize it */
if (location.hash && location.hash.startsWith("#comment-")) {
let commentID = location.hash;
if (!$(commentID).length) {
snackbar("留言已刪除或是不存在。")
} else {
$(window).scrollTop($(commentID).offset().top - $('.comment.header').outerHeight() - 10);
$(commentID).addClass('emphasized');
}
}
if (data.idList.length == 0) {
$('.ts.no-comment.segment').addClass('active');
} else {
$('.ts.no-comment.segment').removeClass('active');
}
}
// Comment Editor & Preview
(function () {
let commentContainer = document.querySelector('#comment');
let textarea = commentContainer.querySelector('textarea');
$('.ts.tabbed.menu a.item[data-tab="preview"]').click(function() {
let comment = textarea.value;
if (comment.trim() != '') {
// reset the container
$('#preview').html('');
parseMarkdown('preview', comment, {
toc: false
});
postProcess();
} else {
$('#preview').html('Nothing to preview!');
}
});
$('#comment textarea').keydown(function (e) {
if (e.ctrlKey && (e.keyCode == 10 || e.keyCode == 13)) { // Ctrl-Enter pressed; Chrome: keyCode == 10
document.querySelector('#comment div[data-tab="textarea"] button.submit.positive').click(); // send comment
}
});
// Edit
$('.ts.comments').on('click', '.edit', function(e) {
if (!textarea.disabled) {
let el = e.currentTarget;
let id = el.dataset.comment;
editorEditComment(textarea, id);
} else {
snackbar("你已被禁言。");
}
});
// Reply
$('.ts.comments').on('click', '.reply', function(e) {
if (!textarea.disabled) {
let el = e.currentTarget;
textarea.value += ` @${el.dataset.username} `;
textarea.focus();
} else {
snackbar("你已被禁言。");
}
});
function editorInitialize(edtior) {
delete commentContainer.dataset.editId;
if ($('#comment .action.buttons button.cancel').length) {
$('#comment .action.buttons button.cancel').remove();
}
if ($('#comment .menu .indicator').length) {
$('#comment .menu .indicator').remove();
}
edtior.value = ""; // empty the textarea
}
function editorEditComment(editor, commentId) {
if (post.idList.indexOf(commentId) == -1) {
snackbar('留言已刪除。');
return undefined;
}
commentContainer.dataset.editId = commentId;
if (!$('#comment .action.buttons button.cancel').length) {
let cancelButton = document.createElement('button');
cancelButton.classList.add('ts', 'cancel', 'button');
cancelButton.innerText = "取消";
commentContainer.querySelector('.action.buttons').appendChild(cancelButton);
cancelButton.addEventListener('click', function () {
editorInitialize(editor);
});
}
if (!$('#comment .menu .indicator').length) {
let indicator = document.createElement('div');
indicator.classList.add('right', 'indicator', 'item');
indicator.innerText = `Editing: ${commentId}`;
commentContainer.querySelector('.menu').appendChild(indicator);
} else {
$('#comment .menu .indicator').text(`Editing: ${commentId}`);
}
editor.value = post.comments[post.idList.indexOf(commentId)].markdown;
editor.focus();
}
// Send Comment
let commentLock = false;
const commentRate = 10; // 1 comment per 10 seconds
$('#comment div[data-tab="textarea"] button.submit.positive').click(function() {
var _this = this;
let content = textarea.value;
if (content.trim() == "") {
snackbar("留言不能為空!");
return false;
}
if (commentLock) {
snackbar(`${commentRate} 秒只能發一則留言。`);
return false;
} else if (!commentContainer.dataset.editId) {
// only new comment should be limited
commentLock = true;
}
if (commentContainer.dataset.editId) {
// edit comment
var commentData = new URLSearchParams({
"edit": commentContainer.dataset.editId,
"content": content
}).toString();
} else {
// new comment
var commentData = new URLSearchParams({
"pid": pid,
"content": content
}).toString();
}
axios.request({
method: "POST",
url: "ajax/comment.php",
data: commentData,
responseType: "json"
}).then(function (res) {
editorInitialize(textarea);
console.log(`Comment sent succeessfully! Comment id is ${res.data["comment_id"]}`);
setTimeout(function() { commentLock = false }, commentRate * 1000); // sec -> microsecond
fetchComments();
}).catch(function (error) {
commentLock = false; // unlock the textarea
if (error.response) {
let res = error.response;
let data = res.data;
console.error(`An error occurred while sending comments of pid ${pid}, status ${res.status}`);
switch (data.status) {
case "empty":
snackbar("留言不能為空!");
break;
case "ratelimit":
let remainSeconds = res.headers['retry-after'];
snackbar(`${commentRate} 秒只能發一則留言。請 ${remainSeconds} 秒後再試!`);
break;
case "muted":
snackbar("你已被禁言。");
$('#comment .ts.fluid.input').addClass('disabled');
$(textarea).attr("placeholder", "你被禁言了。").val(""); // empty the textarea
$(_this).addClass('disabled').text("你被禁言了");
break;
case "author":
snackbar("你不能編輯別人的留言!");
break;
case "nologin":
snackbar("請先登入。");
break;
default:
snackbar("發送失敗。");
break;
}
fetchComments();
} else {
console.error(`An error occurred while sending comments of pid ${pid}`);
}
});
});
})();
// Delete
$('.ts.comments').on('click', '.delete', function(e) {
let el = e.currentTarget;
let id = el.dataset.comment;
swal({
type: 'question',
title: '確定要刪除嗎?',
showCancelButton: true,
confirmButtonText: '確定',
cancelButtonText: '取消',
}).then((result) => {
if (result.value) { // confirm
axios.request({
method: "GET",
url: "ajax/comment.php?del=" + id,
responseType: "json"
}).then(function (_res) {
fetchComments();
}).catch(function (error) {
if (error.response) {
let res = error.response;
console.error(`An error occurred while deleting comment of id ${id}, status ${res.status}`);
} else {
console.error(`An error occurred while deleting comment of id ${id}`);
}
snackbar('刪除失敗。');
});
}
});
});
function snackbar(message) {
ts('.snackbar').snackbar({
content: message
});
}

176
include/js/edit.js Normal file
View File

@ -0,0 +1,176 @@
let edtior;
editormd.urls = {
atLinkBase : "user.php?username="
};
// create editor instance
editor = editormd('markdownEditor', {
height: 450,
path: "https://pandao.github.io/editor.md/lib/",
markdown: document.edit.content.value,
htmlDecode : "script,iframe|on*",
placeholder: '',
styleActiveLine: false,
"font-size": '14px',
emoji: true,
taskList: true,
tex: true,
flowChart: true,
sequenceDiagram: true,
watch: false,
lineNumbers: false,
lineWrapping: false,
toolbarAutoFixed: false,
toolbarIcons : function() {
return [
"search", "|",
"undo", "redo", "|",
"bold", "del", "italic", "|",
"list-ul", "list-ol", "emoji", "html-entities", "|",
"link", "image", "|",
"preview", "fullscreen", "||",
"help", "info",
]
},
toolbarIconsClass: {
preview: 'fa-eye'
},
onload: function() {
var __this__ = this;
$('ul.editormd-menu').addClass('unstyled'); // remove style of TocasUI
$('ul.editormd-menu i[name="emoji"]').parent().click(function () { // remove style of TocasUI from emoji tab (hack)
setTimeout(()=>{ $('ul.editormd-tab-head').addClass('unstyled'); }, 300);
});
this.resize();
loadDraft();
document.edit.title.addEventListener("keydown", function () {
saveDraft(__this__);
})
this.cm.on("change", function(_cm, _changeObj) {
saveDraft(__this__);
});
},
onresize: function() {
if (this.state.preview) {
requestAnimationFrame(()=>{
this.previewed();
this.previewing();
});
}
},
onpreviewing: function() {
this.save();
// use tocas-ui style tables
$('table').each((_i,e) => {
$(e).addClass('ts celled table').css('display','table');
});
// prevent user from destroying page style
var parser = new cssjs();
let stylesheets = document.querySelectorAll('.markdown-body style');
for (let style of stylesheets) {
let ruleSource = style.innerHTML;
let cssObject = parser.parseCSS(ruleSource);
for (let rule of cssObject) {
let valid = false;
let validPrefix = [".markdown-body ", ".editormd-preview-container ", ".markdown-body.editormd-preview-container ", ".editormd-preview-container.markdown-body "];
validPrefix.forEach((e, _i) => {
valid = valid || rule.selector.startsWith(e);
});
if (!rule.selector.startsWith('@')) { // '@keyframe' & '@import'
if (!valid) {
rule.selector = ".editormd-preview-container " + rule.selector;
}
}
}
style.innerHTML = parser.getCSSForEditor(cssObject);
}
}
});
// save draft data
function saveDraft(editor) {
localStorage.setItem('cavern_draft_title', document.edit.title.value);
localStorage.setItem('cavern_draft_id', document.edit.pid.value);
localStorage.setItem('cavern_draft_content', editor.getMarkdown());
localStorage.setItem('cavern_draft_time', new Date().getTime());
}
// Ask if user want to load draft
function loadDraft() {
if ($('#pid').val() == localStorage.getItem('cavern_draft_id')) {
swal({
type: 'question',
title: '要載入上次備份嗎?',
showCancelButton: true,
confirmButtonText: '載入',
cancelButtonText: '取消',
}).then((result) => {
if (result.value) { // confirm
document.edit.title.value = localStorage.getItem('cavern_draft_title');
editor.setValue(localStorage.getItem('cavern_draft_content'));
}
});
}
}
// Post an article
$(document.edit).on('submit', function(e) {
e.preventDefault();
var _this = this;
let fd = new URLSearchParams(new FormData(this)).toString();
axios.request({
method: "POST",
data: fd,
url: "post.php",
headers: {
'Content-Type': "application/x-www-form-urlencoded"
}
}).then(function (res) {
if (_this.pid.value == localStorage.getItem('cavern_draft_id')) {
['id', 'title', 'content', 'time'].forEach((name) => {
localStorage.removeItem(`cavern_draft_${name}`);
});
}
location.href = res.headers["axios-location"];
}).catch(function (error) {
if (error.response) {
location.href = error.response.headers["axios-location"];
}
});
});
// Delete post confirm message
$('.action.column .delete').on('click', function(e) {
e.preventDefault();
var el = this;
var href = el.getAttribute('href');
swal({
type: 'question',
title: '確定要刪除嗎?',
showCancelButton: true,
confirmButtonText: '確定',
cancelButtonText: '取消',
}).then((result) => {
if (result.value) { // confirm
axios.request({
method: "GET",
url: href
}).then(function (res) {
location.href = res.headers["axios-location"];
}).catch(function (error){
if (error.response) {
location.href = error.response.headers["axios-location"];
}
});
}
});
});
// session keep alive
setInterval(function() {
$.get('ajax/posts.php');
}, 5*60*1000);

3
include/js/lib/css.min.js vendored Normal file

File diff suppressed because one or more lines are too long

4604
include/js/lib/editormd.js Normal file

File diff suppressed because it is too large Load Diff

74
include/js/lib/tocas.js Normal file
View File

@ -0,0 +1,74 @@
var Tocas,animationEnd,bindModalButtons,closeModal,contractDropdown,detectDropdown,expandDropdown,quadrant,slider_progressColor,slider_trackColor,z_dropdownActive,z_dropdownHovered,z_dropdownMenu;Tocas=(function(){var compact,dropzoneNumber,emptyArray,filter,isArray,isEmptyOrWhiteSpace,isObject,queue,slice,tocas,ts;ts=void 0;emptyArray=[];slice=emptyArray.slice;filter=emptyArray.filter;queue=[];tocas={};isArray=Array.isArray||function(obj){return obj instanceof Array;};isObject=function(obj){return obj instanceof Object;};isEmptyOrWhiteSpace=function(str){return str===null||str.match(/^\s*$/)!==null;};dropzoneNumber=0;compact=function(array){return filter.call(array,function(item){return item!==null;});};tocas.init=function(selector,context){var dom;dom=void 0;if(typeof selector==='string'){if(selector[0]==='<'){return tocas.fragment(selector);}
selector=selector.trim();if(typeof context!=='undefined'){return ts(selector).find(context);}
dom=tocas.select(document,selector);}else if(tocas.isTocas(selector)){return selector;}else{if(isArray(selector)){dom=compact(selector);}else if(isObject(selector)){dom=[selector];selector=null;}}
return tocas.Tocas(dom,selector);};tocas.fragment=function(selector){var $element,attrObj,attrs,content,contentMatch,contentRegEx,hasAttr,hasContent,i,mainAll,mainAttrs,mainElement,match,noContent,regEx;noContent=/^<([^\/].*?)>$/;regEx=/(?:<)(.*?)( .*?)?(?:>)/;match=regEx.exec(selector);mainAll=match[0];mainElement=match[1];mainAttrs=match[2];hasAttr=typeof mainAttrs!=='undefined';hasContent=!mainAll.match(noContent);if(hasContent){contentRegEx=new RegExp(mainAll+'(.*?)(?:</'+mainElement+'>)$');contentMatch=contentRegEx.exec(selector);content=contentMatch[1];}
if(hasAttr){attrs=mainAttrs.split(/(?:\s)?(.*?)=(?:"|')(.*?)(?:"|')/).filter(Boolean);attrObj={};i=0;while(i<attrs.length){if((i+2)%2===0){attrObj[attrs[i]]=attrs[i+1];}
i++;}}
$element=ts(document.createElement(mainElement));if(hasAttr){$element.attr(attrObj);}
if(hasContent){$element.html(content);}
return $element;};tocas.isTocas=function(obj){return obj instanceof tocas.Tocas;};tocas.select=function(element,selector){var e;try{return slice.call(element.querySelectorAll(selector));}catch(error){e=error;console.log('TOCAS ERROR: Something wrong while selecting '+selector+' element.');}};tocas.Tocas=function(dom,selector){dom=dom||[];dom.__proto__=ts.fn;dom.selector=selector||'';return dom;};ts=function(selector,context){if(typeof selector==='function'){document.addEventListener('DOMContentLoaded',selector);}else{return tocas.init(selector,context);}};ts.fn={each:function(callback){emptyArray.every.call(this,function(index,element){return callback.call(index,element,index)!==false;});return this;},slice:function(){return ts(slice.apply(this,arguments));},eq:function(index){return this.slice(index,index+1);}};if(!window.ts){window.ts=ts;}})(Tocas);ts.fn.on=function(eventName,selector,handler,once){var hasSelector;once=once||false;hasSelector=true;if(typeof selector!=='string'){hasSelector=false;handler=selector;}
if(typeof handler!=='function'){once=handler;}
return this.each(function(){var data,event,eventHandler,events,i;if(typeof this.addEventListener==='undefined'){console.log('TOCAS ERROR: Event listener is not worked with this element.');return false;}
if(typeof this.ts_eventHandler==='undefined'){this.ts_eventHandler={};}
events=eventName.split(' ');for(i in events){event=events[i];if(typeof this.ts_eventHandler[event]==='undefined'){this.ts_eventHandler[event]={registered:false,list:[]};}
if(this.ts_eventHandler[event].registered===false){this.addEventListener(event,function(evt){var e,inSelector;if(typeof this.ts_eventHandler[event]!=='undefined'){for(e in this.ts_eventHandler[event].list){if(typeof this.ts_eventHandler[event].list[e].selector!=='undefined'){inSelector=false;ts(this.ts_eventHandler[event].list[e].selector).each(function(i,el){if(evt.target===el){inSelector=true;}});if(!inSelector){return;}}
this.ts_eventHandler[event].list[e].func.call(this,evt);if(this.ts_eventHandler[event].list[e].once){delete this.ts_eventHandler[event].list[e];}}}});this.ts_eventHandler[event].registered=true;}
eventHandler=this.ts_eventHandler[event].list;data={func:handler,once:once};if(hasSelector){data.selector=selector;}
eventHandler.push(data);this.ts_eventHandler[event].list=eventHandler;}});};ts.fn.one=function(eventName,selector,handler){return this.each(function(){ts(this).on(eventName,selector,handler,true);});};ts.fn.off=function(eventName,handler){return this.each(function(){var e;if(typeof this.ts_eventHandler==='undefined'){return;}
if(typeof this.ts_eventHandler[eventName]==='undefined'){return;}
console.log(handler);if(typeof handler==='undefined'){this.ts_eventHandler[eventName].list=[];return;}
for(e in this.ts_eventHandler[eventName].list){if(handler===this.ts_eventHandler[eventName].list[e].func){delete this.ts_eventHandler[eventName].list[e];}}});};ts.fn.css=function(property,value){var css,cssObject,i;css='';if(property!==null&&value!==null){css=property+':'+value+';';}else if(typeof property==='object'&&!Array.isArray(property)&&value===null){for(i in property){if(property.hasOwnProperty(i)){css+=i+':'+property[i]+';';}}}else if(Array.isArray(property)&&value===null){cssObject={};this.each(function(){var i;for(i in property){cssObject[property[i]]=ts(this).getCss(property[i]);}});return cssObject;}else if(property!==null&&value===null){return ts(this).getCss(property);}
return this.each(function(){if(typeof this.style==='undefined'){return;}
this.style.cssText=this.style.cssText+css;});};ts.fn.hasClass=function(classes){if(0 in this){if(this[0].classList){return this[0].classList.contains(classes);}else{return new RegExp('(^| )'+classes+'( |$)','gi').test(this[0].className);}}};ts.fn.classList=function(){var i;var classes,i;classes=[];if(0 in this){if(this[0].classList){i=0;while(i<this[0].classList.length){classes.push(this[0].classList[i]);i++;}}else{for(i in this[0].className.split(' ')){classes.push(this[0].className.split(' ')[i]);}}}
return classes;};ts.fn.addClass=function(classes){if(classes===null){return;}
return this.each(function(){var i,list;list=classes.split(' ');for(i in list){if(list[i]===''){i++;continue;}
if(this.classList){this.classList.add(list[i]);}else{this.className+=' '+list[i];}}});};ts.fn.removeClass=function(classes){return this.each(function(){var i,list;if(!classes){this.className='';}else{list=classes.split(' ');for(i in list){if(list[i]===''){i++;continue;}
if(this.classList){this.classList.remove(list[i]);}else if(typeof this.className!=='undefined'){this.className=this.className.replace(new RegExp('(^|\\b)'+classes.split(' ').join('|')+'(\\b|$)','gi'),' ');}}}});};ts.fn.toggleClass=function(classes){return this.each(function(){var i,index,list,objClassList;list=void 0;index=void 0;objClassList=void 0;list=classes.split(' ');for(i in list){if(this.classList){this.classList.toggle(list[i]);}else{objClassList=this.className.split(' ');index=list.indexOf(list[i]);if(index>=0){objClassList.splice(index,1);}else{objClassList.push(list[i]);}
this.className=list[i].join(' ');}}});};ts.fn.getCss=function(property){var err;try{if(0 in this){return document.defaultView.getComputedStyle(this[0],null).getPropertyValue(property);}else{return null;}}catch(error){err=error;return null;}};ts.fn.remove=function(){return this.each(function(){this.parentNode.removeChild(this);});};ts.fn.children=function(){var list;list=[];this.each(function(i,el){list.push.apply(list,el.children);});return ts(list);};ts.fn.find=function(selector){var list;if(typeof selector!=='string'){return null;}
list=[];this.each(function(i,el){list.push.apply(list,el.querySelectorAll(selector));});if(list.length){return ts(list);}else{return null;}};ts.fn.parent=function(){if(0 in this){return ts(this[0].parentNode);}else{return null;}};ts.fn.parents=function(selector){var selector;var selector;var parents,that;that=this;selector=selector||null;parents=[];if(selector!==null){selector=ts(selector);}
while(that){that=ts(that).parent()[0];if(!that){break;}
if(selector===null||selector!==null&&Array.prototype.indexOf.call(selector,that)!==-1){parents.push(that);}}
return ts(parents);};ts.fn.closest=function(selector){var selector;var that;that=this;selector=ts(selector);while(true){that=ts(that).parent()[0];if(!that){return null;}
if(Array.prototype.indexOf.call(selector,that)!==-1){return ts(that);}}};ts.fn.contains=function(wants){var isTrue,selector;selector=ts(wants);isTrue=false;this.each(function(i,el){var children,si;children=el.childNodes;si=0;while(si<selector.length){if(Array.prototype.indexOf.call(children,selector[si])!==-1){isTrue=true;}
si++;}});return isTrue;};ts.fn.attr=function(attr,value){value=value===null?null:value;if(typeof attr==='object'&&!value){return this.each(function(){var i;for(i in attr){this.setAttribute(i,attr[i]);}});}else if(attr!==null&&typeof value!=='undefined'){return this.each(function(){this.setAttribute(attr,value);});}else if(attr!==null&&!value){if(0 in this){return this[0].getAttribute(attr);}else{return null;}}};ts.fn.removeAttr=function(attr){return this.each(function(){this.removeAttribute(attr);});};animationEnd='webkitAnimationEnd mozAnimationEnd MSAnimationEnd oanimationend animationend';quadrant=function(el){var height,heightHalf,position,width,widthHalf;position=el.getBoundingClientRect();width=window.innerWidth;widthHalf=width / 2;height=window.innerHeight;heightHalf=height / 2;if(position.left<widthHalf&&position.top<heightHalf){return 2;}else if(position.left<widthHalf&&position.top>heightHalf){return 3;}else if(position.left>widthHalf&&position.top>heightHalf){return 4;}else if(position.left>widthHalf&&position.top<heightHalf){return 1;}};z_dropdownMenu=9;z_dropdownActive=10;z_dropdownHovered=11;slider_trackColor="#e9e9e9";slider_progressColor="rgb(150, 150, 150)";expandDropdown=function(target){return ts(target).css('z-index',z_dropdownActive).removeClass('hidden').addClass('visible').addClass('animating').one(animationEnd,function(){return ts(target).removeClass('animating');});};contractDropdown=function(target){return ts(target).css('z-index',z_dropdownMenu).removeClass('visible').addClass('hidden').addClass('animating').one(animationEnd,function(){return ts(target).removeClass('animating');});};detectDropdown=function(target,event){var hasDropdownParent,isDropdown,isDropdownIcon,isDropdownImage,isDropdownText,isItem,isTsMenuItem,parentIsItem,targetIsDropdown;isDropdown=ts(target).hasClass('dropdown');isDropdownText=ts(event.target).hasClass('text');isDropdownIcon=ts(event.target).hasClass('icon');isDropdownImage=ts(event.target).hasClass('image');hasDropdownParent=ts(event.target).parent().hasClass('dropdown');parentIsItem=ts(event.target).parent().hasClass('item');targetIsDropdown=ts(event.target).hasClass('dropdown');isItem=ts(event.target).hasClass('item');isTsMenuItem=ts(event.target).closest('.ts.menu');if((isTsMenuItem&&isDropdown&&parentIsItem&&targetIsDropdown)||(isTsMenuItem&&isDropdown&&!parentIsItem&&targetIsDropdown)||(isTsMenuItem&&isDropdown&&hasDropdownParent&&parentIsItem)){return expandDropdown(target);}else if((isDropdown&&isItem)||(isDropdown&&parentIsItem)){return contractDropdown('.ts.dropdown.visible');}else if(isDropdown&&isTsMenuItem){return expandDropdown(target);}else if(isDropdown&&targetIsDropdown){return expandDropdown(target);}else if(isDropdown&&isDropdownIcon&&hasDropdownParent){return expandDropdown(target);}else if(isDropdown&&isDropdownImage&&hasDropdownParent){return expandDropdown(target);}else if(isDropdown&&isDropdownText&&hasDropdownParent){return expandDropdown(target);}};ts(document).on('click',function(event){if(ts(event.target).closest('.dropdown:not(.basic)')===null&&!ts(event.target).hasClass('dropdown')){return contractDropdown('.ts.dropdown:not(.basic).visible');}});ts.fn.dropdown=function(command){return this.each(function(){return ts(this).on('click',function(e){ts(this).removeClass('upward downward leftward rightward');if(quadrant(this)===2){ts(this).addClass('downward rightward');}else if(quadrant(this)===3){ts(this).addClass('upward rightward');}else if(quadrant(this)===1){ts(this).addClass('downward leftward');}else if(quadrant(this)===4){ts(this).addClass('upward leftward');}
contractDropdown('.ts.dropdown.visible');return detectDropdown(this,e);});});};ts.fn.checkbox=function(){return this.each(function(){return ts(this).on('click',function(e){var isRadio,name,tsThis;isRadio=ts(this).hasClass('radio');if(isRadio){tsThis=ts(this).find('input[type="radio"]');}else{tsThis=ts(this).find('input[type="checkbox"]');}
if(tsThis===null){}else if(isRadio){name=tsThis.attr('name');ts(`input[type='radio'][name='${name}']`).removeAttr('checked');return tsThis.attr('checked','checked');}else{if(tsThis.attr('checked')==='checked'){return tsThis.removeAttr('checked');}else{return tsThis.attr('checked','checked');}}});});};ts.fn.tablesort=function(){return this.each(function(){var table;if(!ts(this).hasClass("sortable")){return;}
table=this;return ts(this).find("thead th").each(function(i){return ts(this).on("click",function(){var isAsc,sortTable;isAsc=ts(this).hasClass('ascending');ts(this).closest('thead').find('th').removeClass('sorted ascending descending');sortTable=function(table,col,reverse){var element,j,len,results,tb,tr;tb=table.tBodies[0];tr=Array.prototype.slice.call(tb.rows,0);reverse=-((+reverse)||-1);tr=tr.sort(function(a,b){return reverse*(a.cells[col].textContent.trim().localeCompare(b.cells[col].textContent.trim()));});results=[];for(j=0,len=tr.length;j<len;j++){element=tr[j];results.push(tb.appendChild(element));}
return results;};sortTable(table,i,isAsc);return ts(this).addClass(isAsc?'sorted descending':'sorted ascending');});});});};closeModal=function(modal){if(ts(modal).hasClass('opening')||ts(modal).hasClass('closing')){return;}
ts(modal).closest('.ts.modals.dimmer').addClass('closing').one(animationEnd,function(){var dimmer;dimmer=this;return setTimeout(function(){ts(dimmer).removeClass('closing').removeClass('active');return ts('body').removeAttr('data-modal-lock');},30);});return ts(modal).addClass('closing').one(animationEnd,function(){return ts(this).removeClass('closing').removeAttr('open');});};bindModalButtons=function(modal,approve,deny,approveCallback,denyCalback,overwrite){var isset,tsApprove,tsDeny;tsApprove=ts(modal).find(approve);tsDeny=ts(modal).find(deny);isset=ts(modal).attr("data-modal-initialized")!==null;if(tsApprove!==null){if(overwrite){tsApprove.off('click');}
if(overwrite||!isset&&!overwrite){tsApprove.on('click',function(){if(approveCallback.call(modal)!==false){return closeModal(modal);}});}}
if(tsDeny!==null){if(overwrite){tsDeny.off('click');}
if(overwrite||!isset&&!overwrite){tsDeny.on('click',function(){if(denyCalback.call(modal)!==false){return closeModal(modal);}});}}
return ts(modal).attr('data-modal-initialized','true');};ts.fn.modal=function(option){return this.each(function(i){var approve,closeBtn,deny,modal,onApprove,onDeny,tsDimmer,tsModal;if(i>0||typeof this==='undefined'){return;}
modal=this;tsModal=ts(this);tsDimmer=tsModal.closest('.ts.modals.dimmer');closeBtn=tsModal.find('.close.icon');if(tsDimmer===null){return;}
if(option==='show'){ts('body').attr('data-modal-lock','true');tsDimmer.addClass('active').addClass('opening').one(animationEnd,function(){return ts(this).removeClass('opening');}).on('click',function(e){if(ts(modal).hasClass('closable')){if(e.target===this){return closeModal(modal);}}});if(closeBtn!==null){closeBtn.on('click',function(){return closeModal(modal);});}
bindModalButtons(modal,'.positive, .approve, .ok','.negative, .deny, .cancel',function(){return true;},function(){return true;},false);return tsModal.attr('open','open').addClass('opening').one(animationEnd,function(){return tsModal.removeClass('opening');});}else if(option==='hide'){return closeModal(this);}else if(typeof option==='object'){approve=option.approve||'.positive, .approve, .ok';deny=option.deny||'.negative, .deny, .cancel';onDeny=option.onDeny||function(){return true;};onApprove=option.onApprove||function(){return true;};modal=this;return bindModalButtons(modal,approve,deny,onApprove,onDeny,true);}});};ts.fn.sidebar=function(options,selector,eventName){var closable,closeVisibleSidebars,dimPage,exclusive,pusher,scrollLock;dimPage=(options!=null?options.dimPage:void 0)||false;exclusive=(options!=null?options.exclusive:void 0)||false;scrollLock=(options!=null?options.scrollLock:void 0)||false;closable=(options!=null?options.closable:void 0)||true;pusher=document.querySelector('.pusher');closeVisibleSidebars=function(){ts('.ts.sidebar.visible:not(.static)').addClass('animating').removeClass('visible').one(animationEnd,function(){return ts(this).removeClass('animating');});return ts('.pusher').removeClass('dimmed').removeAttr('data-pusher-lock');};if(pusher.getAttribute('data-closable-bind')!=='true'){pusher.addEventListener('click',function(e){if(pusher.getAttribute('data-sidebar-closing')!=='true'){return closeVisibleSidebars();}});}
pusher.setAttribute('data-closable-bind',true);return this.each(function(){var that;if(options==='toggle'||options==='hide'||options==='show'){ts(this).addClass('animating');pusher.setAttribute('data-sidebar-closing','true');setTimeout(function(){return pusher.removeAttribute('data-sidebar-closing');},300);if(this.getAttribute('data-dim-page')===null){this.setAttribute('data-dim-page',dimPage);}
if(this.getAttribute('data-scroll-lock')===null){this.setAttribute('data-scroll-lock',scrollLock);}
if(!ts(this).hasClass('visible')&&options==='hide'){ts(this).removeClass('animating');}
if((ts(this).hasClass('visible')&&options==='toggle')||options==='hide'){ts('.pusher').removeClass('dimmed').removeAttr('data-pusher-lock');return ts(this).removeClass('visible').one(animationEnd,function(){return ts(this).removeClass('animating');});}else{if(this.getAttribute('data-exclusive')==='true'){closeVisibleSidebars();}
if(this.getAttribute('data-dim-page')==='true'){ts('.pusher').addClass('dimmed');}
if(this.getAttribute('data-scroll-lock')==='true'){ts('.pusher').attr('data-pusher-lock','true');}
return ts(this).addClass('visible').removeClass('animating');}}else if(options==='attach events'){that=this;switch(eventName){case'show':return ts(selector).attr('data-sidebar-trigger','true').on('click',function(){return ts(that).sidebar('show');});case'hide':return ts(selector).attr('data-sidebar-trigger','true').on('click',function(){return ts(that).sidebar('hide');});case'toggle':return ts(selector).attr('data-sidebar-trigger','true').on('click',function(){return ts(that).sidebar('toggle');});}}else if(typeof options==='object'){this.setAttribute('data-closable',closable);this.setAttribute('data-scroll-lock',scrollLock);this.setAttribute('data-exclusive',exclusive);return this.setAttribute('data-dim-page',dimPage);}});};ts.fn.tab=function(option){return this.each(function(){var onSwitch;onSwitch=(option!=null?option.onSwitch:void 0)||function(){};return ts(this).on('click',function(){var tabGroup,tabName;if(ts(this).hasClass('active')){return;}
tabName=ts(this).attr('data-tab');if(tabName===null){return;}
tabGroup=ts(this).attr('data-tab-group');onSwitch(tabName,tabGroup);if(tabGroup===null){ts('[data-tab]:not(.tab):not([data-tab-group])').removeClass('active');ts('[data-tab]:not([data-tab-group])').removeClass('active');ts(`.tab[data-tab='${tabName}']:not([data-tab-group])`).addClass('active');}else{ts(`[data-tab-group='${tabGroup}']:not(.tab)`).removeClass('active');ts(`.tab[data-tab-group='${tabGroup}']`).removeClass('active');ts(`.tab[data-tab='${tabName}'][data-tab-group='${tabGroup}']`).addClass('active');}
return ts(this).addClass('active');});});};ts.fn.popup=function(){return this.each(function(){var android,iOS,userAgent,winPhone;userAgent=navigator.userAgent||navigator.vendor||window.opera;winPhone=new RegExp("windows phone","i");android=new RegExp("android","i");iOS=new RegExp("iPad|iPhone|iPod","i");if(winPhone.test(userAgent)||android.test(userAgent)||(iOS.test(userAgent)&&!window.MSStream)){return ts(this).addClass('untooltipped');}});};ts.fn.slider=function(option){var counter,modify,outerCounter;outerCounter=option!=null?option.outerCounter:void 0;counter=option!=null?option.counter:void 0;modify=function(sliderEl,inputEl,counter,outerCounter){var counterEl,value;value=(inputEl.value-inputEl.getAttribute('min'))/(inputEl.getAttribute('max'-inputEl.getAttribute('min')));if(value===Number.POSITIVE_INFINITY){value=inputEl.value / 100;}
if(counter!=null){counterEl=ts(sliderEl).find(counter);if(counterEl!=null){counterEl[0].innerText=inputEl.value;}}
if(outerCounter!=null){ts(outerCounter).innerText=inputEl.value;}
return ts(inputEl).css('background-image',`-webkit-gradient(linear,left top,right top,color-stop(${value},${slider_progressColor}),color-stop(${value},${slider_trackColor}))`);};return this.each(function(){var inputEl,sliderEl;sliderEl=this;inputEl=ts(this).find('input[type="range"]');modify(this,inputEl[0],counter,outerCounter);return inputEl.on('input',function(){return modify(sliderEl,this,counter,outerCounter);});});};ts.fn.editable=function(option){var autoClose,autoReplace,inputWrapper,onEdit,onEdited;autoReplace=(option!=null?option.autoReplace:void 0)||true;onEdit=(option!=null?option.onEdit:void 0)||function(){};onEdited=(option!=null?option.onEdited:void 0)||function(){};autoClose=(option!=null?option.autoClose:void 0)||true;inputWrapper=this;if(autoClose){ts(document).on('click',function(event){if(ts(event.target).closest('.ts.input')===null){return inputWrapper.each(function(){var contenteditable,input,text;input=ts(this).find('input');contenteditable=ts(this).find('[contenteditable]');text=ts(this).find('.text')[0];if(autoReplace){if(input!=null){text.innerText=input[0].value;}else if(contenteditable!=null){text.innerText=contenteditable[0].value;}}
onEdited(this);return ts(this).removeClass('editing');});}});}
return this.each(function(){var contenteditable,input;input=ts(this).find('input');contenteditable=ts(this).find('[contenteditable]');return ts(this).on('click',function(){ts(this).addClass('editing');onEdit(this);if(input!=null){return input[0].focus();}else if(contenteditable!=null){return contenteditable[0].focus();}});});};ts.fn.message=function(){return this.each(function(){return ts(this).find('i.close').on('click',function(){return ts(this).closest('.ts.message').addClass('hidden');});});};ts.fn.snackbar=function(option){var action,actionEmphasis,content,hoverStay,interval,onAction,onClose;content=(option!=null?option.content:void 0)||null;action=(option!=null?option.action:void 0)||null;actionEmphasis=(option!=null?option.actionEmphasis:void 0)||null;onClose=(option!=null?option.onClose:void 0)||function(){};onAction=(option!=null?option.onAction:void 0)||function(){};hoverStay=(option!=null?option.hoverStay:void 0)||false;interval=3500;if(content===null){return;}
return this.each(function(){var ActionEl,close,contentEl,snackbar;snackbar=this;contentEl=ts(snackbar).find('.content');ActionEl=ts(snackbar).find('.action');ts(snackbar).removeClass('active animating').addClass('active animating').one(animationEnd,function(){return ts(this).removeClass('animating');}).attr('data-mouseon','false');contentEl[0].innerText=content;if(ActionEl!=null){ActionEl[0].innerText=action;}
if((actionEmphasis!=null)&&(ActionEl!=null)){ActionEl.removeClass('primary info warning negative positive').addClass(actionEmphasis);}
close=function(){ts(snackbar).removeClass('active').addClass('animating').one(animationEnd,function(){ts(this).removeClass('animating');return onClose(snackbar,content,action);});return clearTimeout(snackbar.snackbarTimer);};if(ActionEl!=null){ActionEl.off('click');ActionEl.on('click',function(){close();return onAction(snackbar,content,action);});}
if(hoverStay){ts(snackbar).on('mouseenter',function(){return ts(this).attr('data-mouseon','true');});ts(snackbar).on('mouseleave',function(){return ts(this).attr('data-mouseon','false');});}
clearTimeout(snackbar.snackbarTimer);return snackbar.snackbarTimer=setTimeout(function(){var hoverChecker;if(hoverStay){return hoverChecker=setInterval(function(){if(ts(snackbar).attr('data-mouseon')==='false'){close();return clearInterval(hoverChecker);}},600);}else{return close();}},interval);});};ts.fn.contextmenu=function(option){var menu;menu=(option!=null?option.menu:void 0)||null;ts(document).on('click',function(event){return ts('.ts.contextmenu.visible').removeClass('visible').addClass('hidden animating').one(animationEnd,function(){return ts(this).removeClass('visible animating downward upward rightward leftward');});});return this.each(function(){return ts(this).on('contextmenu',function(e){var h,r,w;event.preventDefault();ts(menu).addClass('visible');r=ts(menu)[0].getBoundingClientRect();ts(menu).removeClass('visible');w=window.innerWidth / 2;h=window.innerHeight / 2;ts(menu).removeClass('downward upward rightward leftward');if(e.clientX<w&&e.clientY<h){ts(menu).addClass('downward rightward').css('left',e.clientX+'px').css('top',e.clientY+'px');}else if(e.clientX<w&&e.clientY>h){ts(menu).addClass('upward rightward').css('left',e.clientX+'px').css('top',e.clientY-r.height+'px');}else if(e.clientX>w&&e.clientY>h){ts(menu).addClass('upward leftward').css('left',e.clientX-r.width+'px').css('top',e.clientY-r.height+'px');}else if(e.clientX>w&&e.clientY<h){ts(menu).addClass('downward leftward').css('left',e.clientX-r.width+'px').css('top',e.clientY+'px');}
return ts(menu).removeClass('hidden').addClass('visible animating').one(animationEnd,function(){return ts(this).removeClass('animating');});});});};ts.fn.embed=function(option){return this.each(function(){var embedEl,icon,iconEl,id,options,placeholder,placeholderEl,query,source,url;source=this.getAttribute('data-source');url=this.getAttribute('data-url');id=this.getAttribute('data-id');placeholder=this.getAttribute('data-placeholder');options=this.getAttribute('data-options')||'';query=this.getAttribute('data-query')||'';icon=this.getAttribute('data-icon')||'video play';embedEl=this;if(this.getAttribute('data-embed-actived')){return;}
if(query!==''){query='?'+query;}
if(placeholder){placeholderEl=document.createElement('img');placeholderEl.src=placeholder;placeholderEl.className='placeholder';this.appendChild(placeholderEl);}
if(icon&&(source||url||id)){iconEl=document.createElement('i');iconEl.className=icon+' icon';ts(iconEl).on('click',function(){var iframeEl,urlExtension,videoEl;urlExtension=url?url.split('.').pop():'';if(urlExtension.toUpperCase().indexOf('MOV')!==-1||urlExtension.toUpperCase().indexOf('MP4')!==-1||urlExtension.toUpperCase().indexOf('WEBM')!==-1||urlExtension.toUpperCase().indexOf('OGG')!==-1){videoEl=document.createElement('video');videoEl.src=url;if(options!==''){options.split(',').forEach(function(pair){var key,p,value;p=pair.split('=');key=p[0];value=p[1]||'';return videoEl.setAttribute(key.trim(),value.trim());});}
ts(embedEl).addClass('active');return embedEl.appendChild(videoEl);}else{iframeEl=document.createElement('iframe');iframeEl.width='100%';iframeEl.height='100%';iframeEl.frameborder='0';iframeEl.scrolling='no';iframeEl.setAttribute('webkitAllowFullScreen','');iframeEl.setAttribute('mozallowfullscreen','');iframeEl.setAttribute('allowFullScreen','');if(source){switch(source){case'youtube':iframeEl.src='https://www.youtube.com/embed/'+id+query;break;case'vimeo':iframeEl.src='https://player.vimeo.com/video/'+id+query;}}else if(url){iframeEl.src=url+query;}
ts(embedEl).addClass('active');return embedEl.appendChild(iframeEl);}});this.appendChild(iconEl);}
return this.setAttribute('data-embed-actived','true');});};ts.fn.accordion=function(){};ts.fn.scrollspy=function(options){var anchors,container,target,tsTarget;target=document.querySelector(options.target);tsTarget=ts(target);container=this[0];anchors=document.querySelectorAll(`[data-scrollspy='${target.id}']`);if(this[0]===document.body){container=document;}
return Array.from(anchors).forEach(function(element,index,array){var anchor,event,link;anchor=element;link=`[href='#${anchor.id}']`;event=function(){var containerRect,containerTop,continerIsBottom,length,rect;rect=anchor.getBoundingClientRect();if(container===document){containerRect=document.documentElement.getBoundingClientRect();continerIsBottom=document.body.scrollHeight-(document.body.scrollTop+window.innerHeight)===0;}else{containerRect=container.getBoundingClientRect();continerIsBottom=container.scrollHeight-(container.scrollTop+container.clientHeight)===0;}
containerTop=containerRect.top<0?0:containerRect.top;if(rect.top-containerTop<10||(continerIsBottom&&(index===array.length-1))){tsTarget.find(link).addClass('active');length=tsTarget.find('.active').length;return tsTarget.find('.active').each(function(index){if(index!==length-1){return ts(this).removeClass('active');}});}else{return tsTarget.find(link).removeClass('active');}};event.call(this);container.addEventListener('scroll',event);return window.addEventListener('hashchange',event);});};

127
include/js/lib/zh-tw.js Normal file
View File

@ -0,0 +1,127 @@
(function(){
var factory = function (exports) {
var lang = {
name : "zh-tw",
description : "開源在線Markdown編輯器<br/>Open source online Markdown editor.",
tocTitle : "選單",
toolbar : {
undo : "復原Ctrl+Z",
redo : "重做Ctrl+Y",
bold : "粗體",
del : "刪除線",
italic : "斜體",
quote : "引用",
ucwords : "將所選的每個單字首字母轉成大寫",
uppercase : "將所選文字轉成大寫",
lowercase : "將所選文字轉成小寫",
h1 : "標題1",
h2 : "標題2",
h3 : "標題3",
h4 : "標題4",
h5 : "標題5",
h6 : "標題6",
"list-ul" : "無序清單",
"list-ol" : "有序清單",
hr : "分隔線",
link : "連結",
"reference-link" : "引用連結",
image : "圖片",
code : "行內代碼",
"preformatted-text" : "預格式文本 / 代碼塊(縮進風格)",
"code-block" : "代碼塊(多語言風格)",
table : "添加表格",
datetime : "日期時間",
emoji : "Emoji 表情",
"html-entities" : "HTML 實體字符",
pagebreak : "插入分頁符",
watch : "關閉實時預覽",
unwatch : "開啟實時預覽",
preview : "預覽(按 Shift + ESC 退出)",
fullscreen : "全螢幕(按 ESC 退出)",
clear : "清空",
search : "搜尋",
help : "幫助",
info : "關於" + exports.title
},
buttons : {
enter : "確定",
cancel : "取消",
close : "關閉"
},
dialog : {
link : {
title : "添加連結",
url : "連結位址",
urlTitle : "連結標題",
urlEmpty : "錯誤:請填寫連結位址。"
},
referenceLink : {
title : "添加引用連結",
name : "引用名稱",
url : "連結位址",
urlId : "連結ID",
urlTitle : "連結標題",
nameEmpty: "錯誤:引用連結的名稱不能為空。",
idEmpty : "錯誤請填寫引用連結的ID。",
urlEmpty : "錯誤請填寫引用連結的URL地址。"
},
image : {
title : "添加圖片",
url : "圖片位址",
link : "圖片連結",
alt : "圖片描述",
uploadButton : "本地上傳",
imageURLEmpty : "錯誤:圖片地址不能為空。",
uploadFileEmpty : "錯誤:上傳的圖片不能為空!",
formatNotAllowed : "錯誤:只允許上傳圖片文件,允許上傳的圖片文件格式有:"
},
preformattedText : {
title : "添加預格式文本或代碼塊",
emptyAlert : "錯誤:請填寫預格式文本或代碼的內容。"
},
codeBlock : {
title : "添加代碼塊",
selectLabel : "代碼語言:",
selectDefaultText : "請語言代碼語言",
otherLanguage : "其他語言",
unselectedLanguageAlert : "錯誤:請選擇代碼所屬的語言類型。",
codeEmptyAlert : "錯誤:請填寫代碼內容。"
},
htmlEntities : {
title : "HTML實體字符"
},
help : {
title : "幫助"
}
}
};
exports.defaults.lang = lang;
};
// CommonJS/Node.js
if (typeof require === "function" && typeof exports === "object" && typeof module === "object")
{
module.exports = factory;
}
else if (typeof define === "function") // AMD/CMD/Sea.js
{
if (define.amd) { // for Require.js
define(["editormd"], function(editormd) {
factory(editormd);
});
} else { // for Sea.js
define(function(require) {
var editormd = require("../editormd");
factory(editormd);
});
}
}
else
{
factory(window.editormd);
}
})();

46
include/js/like.js Normal file
View File

@ -0,0 +1,46 @@
$('#content').on('click', 'button.like.button', function(e){
var el = e.currentTarget;
var id = el.dataset.id;
axios.request({
method: "GET",
url: "./ajax/like.php?pid=" + id,
responseType: "json",
}).then(function (res) {
var data = res.data;
if (data.status == true) {
$(`button.like.button[data-id="${data.id}"]`).html(
'<i class="thumbs up icon"></i> ' + data.likes
);
} else if (data.status == false) {
$(`button.like.button[data-id="${data.id}"]`).html(
'<i class="thumbs outline up icon"></i> ' + data.likes
);
}
}).catch(function (error) {
if (error.response) {
let data = error.response.data;
if (data.status == 'nologin') {
$(`button.like.button[data-id="${data.id}"]`).html(
'<i class="thumbs outline up icon"></i> ' + data.likes
);
swal({
type: 'warning',
title: '請先登入!',
text: '登入以按讚或發表留言。',
showCancelButton: true,
confirmButtonText: '登入',
cancelButtonText: '取消',
}).then((result) => {
if (result.value) { // confirm
location.href = 'login.php';
}
});
}
} else {
$(`button.like.button[data-id="${id}"]`).html(
'<i class="thumbs outline up icon"></i> ' + "--"
);
console.error(`An error occurred when get likes of pid ${id}, status ${error.response.status}`);
}
});
});

40
include/js/markdown.js Normal file
View File

@ -0,0 +1,40 @@
editormd.urls = {
atLinkBase : "user.php?username="
};
function postProcess(...callbacks) {
tableStyling();
linkSanitize();
callbacks.forEach(func => {
func();
});
function linkSanitize() {
$('.markdown-body a').each((_i, e) => {
href = (e.getAttribute('href')) ? _.unescape(e.getAttribute('href').toLowerCase()) : "";
if (href.indexOf('javascript:') != -1) {
e.setAttribute('href', '#');
}
});
}
function tableStyling() {
$('table').each((_i,e) => {
$(e).addClass("ts celled table").css('display', 'table').wrap('<div class="table wrapper"></div>');
});
}
}
function parseMarkdown(id, markdown, options) {
let defaultOptions = {
htmlDecode : "script,iframe|on*",
toc: true,
emoji: true,
taskList: true,
tex: true,
flowChart: true,
sequenceDiagram: true
}
return editormd.markdownToHTML(id, $.extend(true, defaultOptions, options, { markdown: markdown }));
}

126
include/js/notification.js Normal file
View File

@ -0,0 +1,126 @@
var notifications = {
toFetch: true,
unreadCount: 0,
feeds: []
};
$('#menu .notification.icon.item').on('click', function (e) {
e.preventDefault();
let el = e.currentTarget;
let $wrapper = $('#notification-wrapper');
let $container = $('.notification.container');
if ($container.hasClass('active')) {
// dismiss the notification window
$('.notification.click.handler').remove();
} else {
// render the notification window
let handler = document.createElement('div')
handler.className = "notification click handler";
$wrapper.after(handler);
handler.addEventListener('click', function (e) {
el.click();
});
setNotificationCounter(0); // remove counter
if (notifications.toFetch){
fetchNotification();
}
}
el.classList.toggle('active');
$container.toggleClass('active');
});
function fetchNotificationCount() {
axios.request({
method: 'GET',
url: "./ajax/notification.php?count",
responseType: 'json'
}).then(function (res) {
let count = res.data['unread_count'];
setNotificationCounter(count);
if (count != notifications.unreadCount) {
// if count changes, fetching notifications while next click
notifications.toFetch = true;
notifications.unreadCount = count;
}
}).catch(function (_error) {
console.error("Error occurred while fetching notification count.");
});
}
window.addEventListener('load', function () {
fetchNotificationCount();
});
var notificationFetchTimer = setInterval(fetchNotificationCount, 1 * 5 * 1000); // fetch notification count every 1 minute
function fetchNotification() {
axios.request({
method: 'GET',
url: './ajax/notification.php?fetch',
responseType: 'json'
}).then(function (res) {
parseNotification(res.data);
notifications.toFetch = false;
}).catch(function (error) {
console.log("Error occurred while fetching notification count.");
});
}
function parseNotification(data) {
const feedTemplate = `<div class="event"><div class="label"><i class="volume up icon"></i></div><div class="content"><div class="date">{{ time }}</div><div class="summary">{{ message }}</div></div></div>`;
let $feed = $('.ts.feed');
$feed.html(""); // container clean up
for (f of data.feeds) {
let message = parseMessage(f.message, f.url);
let node = feedTemplate.replace("{{ time }}", f.time).replace("{{ message }}", message);
$node = $(node).appendTo($feed);
if (f.read == 0) {
$node.addClass('unread');
}
}
notifications.feeds = data.feeds; // cache data
function parseMessage(message, url) {
let regex = {
"username": /\{([^\{\}]+)\}@(\w+)/g,
"url": /\[([^\[\[]*)\]/g
};
return message.replace(regex.username, function (_match, name, id, _offset, _string) {
return `<a href="user.php?username=${id}">${name}</a>`;
}).replace(regex.url, function (_match, title, _offset, _string) {
return `<a href="${url}">${title}</a>`;
});
}
}
function setNotificationCounter(count) {
let $notify = $('#menu .notification.icon.item');
let $icon = $notify.children('i.icon');
let $counter = $notify.children('span.counter');
if (count == 0) {
if ($counter.length) {
$counter.remove();
}
$icon.toggleClass('outline', true); // set icon style
} else {
if ($counter.length) {
$counter.text(count);
} else {
let counter = document.createElement('span');
counter.className = "counter";
counter.textContent = count;
$notify.append(counter);
}
$icon.toggleClass('outline', false); // set icon style
}
return count;
}

86
include/js/post.js Normal file
View File

@ -0,0 +1,86 @@
const cdnjs = "https://cdnjs.cloudflare.com/ajax/libs";
// Load Libraries
const libraries = [
cdnjs + "/marked/0.5.1/marked.min.js",
cdnjs + "/prettify/r298/prettify.min.js",
cdnjs + "/raphael/2.2.7/raphael.min.js",
cdnjs + "/underscore.js/1.9.1/underscore-min.js",
cdnjs + "/flowchart/1.11.3/flowchart.min.js",
"https://pandao.github.io/editor.md/lib/jquery.flowchart.min.js",
cdnjs + "/js-sequence-diagrams/1.0.6/sequence-diagram-min.js"
];
loadJS(libraries).then(function () {
editormd.$marked = marked;
editormd.loadFiles.js.push(...libraries.map(url => url.slice(0, -3))); // remove ".js"
parsePost();
fetchComments();
postProcess(sanitizeStyleTag());
function sanitizeStyleTag() { // prevent the style tag in post from destorying the style of page
return function() {
var parser = new cssjs();
let stylesheets = document.querySelectorAll('#post style');
for (let style of stylesheets) {
let ruleSource = style.innerHTML;
let cssObject = parser.parseCSS(ruleSource);
for (let rule of cssObject) {
let valid = false;
let validPrefix = ["#post ", "#post.markdown-body ", "#post.editormd-html-preview "];
validPrefix.forEach((e, _i) => {
valid = valid || rule.selector.startsWith(e);
});
if (!rule.selector.startsWith('@')) { // '@keyframe' & '@import'
if (valid) {
// do nothing
} else if (rule.selector.startsWith('.markdown-body ') || rule.selector.startsWith(".editormd-html-preview")) {
rule.selector = "#post" + rule.selector;
} else {
rule.selector = "#post " + rule.selector;
}
}
}
style.innerHTML = parser.getCSSForEditor(cssObject);
}
}
}
});
function parsePost() {
var postContent = document.querySelector('#post .markdown').textContent;
if (postContent.search(/.{0}\[TOC\]\n/) != -1) { // if TOC is used in post
$('#sidebar .ts.fluid.input').after(`<div class="ts tertiary top attached center aligned segment">目錄</div><div class="ts bottom attached loading segment" id="toc"></div>`);
}
parseMarkdown('post', postContent, {
tocDropdown: false,
tocContainer: '#toc'
}).children('.markdown').hide();
$('#toc').removeClass('loading');
}
// Delete post confirm message
$('.action.column .delete').on('click', function(e) {
e.preventDefault();
var el = this;
var next = el.getAttribute('href');
swal({
type: 'question',
title: '確定要刪除嗎?',
showCancelButton: true,
confirmButtonText: '確定',
cancelButtonText: '取消',
}).then((result) => {
if (result.value) { // confirm
axios.request({
method: "GET",
url: next
}).then(function (res) {
location.href = res.headers["axios-location"];
});
}
});
});

19
include/js/security.js Normal file
View File

@ -0,0 +1,19 @@
axios.defaults.withCredentials = true;
axios.interceptors.request.use(function (config) {
var crypto = window.crypto || window.msCrypto;
let csrfToken = btoa(String(crypto.getRandomValues(new Uint32Array(1))[0]));
document.cookie = `${axios.defaults.xsrfCookieName}=${csrfToken}`;
return config;
}, function (error) {
return Promise.reject(error);
});
$("#logout").on("click", function (e) {
e.preventDefault();
axios.get("login.php?logout").then(function (res) {
location.href = res.headers["axios-location"];
}).catch(function (error) {
console.log(error);
});
});

21
include/notification.php Normal file
View File

@ -0,0 +1,21 @@
<?php
function cavern_notify_user($username, $message="你有新的通知!", $url="", $type="") {
global $SQL;
$time = date('Y-m-d H:i:s');
$SQL->query("INSERT INTO `notification` (`username`, `message`, `url`, `type`, `time`) VALUES ('%s', '%s', '%s', '%s', '%s')", array($username, $message, $url, $type, $time));
}
function parse_user_tag($markdown) {
$regex = array(
"code_block" => "/(`{1,3}[^`]*`{1,3})/",
"email" => "/[^@\s]*@[^@\s]*\.[^@\s]*/",
"username" => "/@(\w+)/"
);
$tmp = preg_replace($regex["code_block"], " ", $markdown);
$tmp = preg_replace($regex["email"], " ", $tmp);
preg_match_all($regex["username"], $tmp, $username_list);
return array_unique($username_list[1]);
}

9
include/security.php Normal file
View File

@ -0,0 +1,9 @@
<?php
function validate_csrf() {
if (isset($_COOKIE["XSRF-TOKEN"]) && isset($_SERVER["HTTP_X_XSRF_TOKEN"]) && ($_COOKIE["XSRF-TOKEN"] === $_SERVER["HTTP_X_XSRF_TOKEN"])) {
return TRUE;
} else {
return FALSE;
}
}
?>

65
include/user.php Normal file
View File

@ -0,0 +1,65 @@
<?php
class NoUserException extends Exception {}
class User {
private $valid = false;
private $islogin = false;
private $username;
private $name;
private $level;
private $muted;
private $email;
public function __construct($username="") {
// if $username is empty indicates that user is not logged in
if ($username !== "") {
// the user might be banned or removed, so we validate him here
$query = cavern_query_result("SELECT * FROM `user` WHERE `username` = '%s'", array($username));
if ($query['num_rows'] > 0){
$this->valid = true;
$data = $query['row'];
$this->username = $data["username"];
$this->name = $data['name'];
$this->level = $data['level'];
$this->muted = ($data['muted'] == 1 ? true : false);
$this->email = $data['email'];
} else {
throw new NoUserException($username);
}
if ($this->username === @$_SESSION["cavern_username"]) {
$this->islogin = true;
}
} else {
// even though the user hasn't logged in, he is still a valid user
$this->username = "";
$this->valid = true;
}
}
public function __get($name) {
return $this->$name;
}
}
function validate_user() {
if (isset($_SESSION['cavern_username'])) {
$username = $_SESSION['cavern_username'];
} else {
$username = "";
}
try {
$user = new User($username);
} catch (NoUserException $e) {}
if (!$user->valid) {
session_destroy();
}
return $user;
}
?>

100
include/view.php Normal file
View File

@ -0,0 +1,100 @@
<?php
/* Cavern Edition
modified by t510599 at 2019/05/30
*/
/*
<Secret Blog>
Copyright (C) 2012-2017 太陽部落格站長 Secret <http://gdsecret.com>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, version 3.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<http://www.gnu.org/licenses/>.
*/
class View {
private $master_content;
private $nav_content;
private $sidebar_content;
private $message = array();
private $script = array();
private $title;
private $part;
public function __construct($master,$nav,$sidebar,$title,$part) {
$this->load($master,$nav,$sidebar);
$this->title = $title;
$this->part = $part;
ob_start();
}
private function load($master,$nav,$sidebar) {
ob_start();
include($master);
$this->master_content = ob_get_contents();
ob_end_clean();
ob_start();
include($nav);
$this->nav_content = ob_get_contents();
ob_end_clean();
if ($sidebar!='') {
ob_start();
include($sidebar);
$this->sidebar_content = ob_get_contents();
ob_end_clean();
}
}
public function add_script($src) {
$this->script[] = "<script src=\"$src\"></script>";
}
public function add_script_source($source) {
$this->script[] = "<script>$source</script>";
}
public function show_message($class, $msg) {
$this->message[] = "<div class=\"ts $class message\"><p>$msg</p></div>";
}
public function render() {
$content = ob_get_contents();
ob_end_clean();
echo strtr($this->master_content, array(
'{title}' => $this->title,
'{part}' => $this->part,
'{script}' => join(PHP_EOL, $this->script),
'{nav}' => $this->nav_content,
'{sidebar}' => $this->sidebar_content,
'{message}' => join(PHP_EOL, $this->message),
'{content}' => $content
));
@ob_flush();
flush();
}
};

132
index.php Normal file
View File

@ -0,0 +1,132 @@
<?php
require_once('connection/SQL.php');
require_once('config.php');
require_once('include/view.php');
require_once('include/user.php');
require_once('include/article.php');
$user = validate_user();
if (!$user->valid) {
http_response_code(403);
header("Location: index.php?err=account");
exit;
}
$all_posts_count = cavern_query_result("SELECT COUNT(*) AS `count` FROM `post`")['row']['count'];
if (isset($_GET['page']) && trim($_GET['page']) != "") {
$limit_start = abs((intval($_GET['page']) - 1) * $blog['limit']);
if ($limit_start > $all_posts_count) { // we don't have that much posts
header('Location: index.php');
exit;
}
} else {
$limit_start = 0;
}
$post_list = article_list(cavern_query_result(
"SELECT `post`.*, `user`.name FROM `post` INNER JOIN `user` ON `post`.username = `user`.username ORDER BY `time` DESC LIMIT %d,%d",
array($limit_start, $blog['limit']))
);
if ($user->islogin) {
$view = new View('theme/default.html', 'theme/nav/util.php', 'theme/sidebar.php', $blog['name'], "首頁");
$view->add_script_source("ts('.ts.dropdown:not(.basic)').dropdown();");
} else {
$view = new View('theme/default.html', 'theme/nav/default.html', 'theme/sidebar.php', $blog['name'], "首頁");
}
$view->add_script("https://unpkg.com/load-js@1.2.0");
$view->add_script("./include/js/lib/editormd.js");
$view->add_script("./include/js/security.js");
$view->add_script('./include/js/markdown.js');
$view->add_script('./include/js/cards.js');
$view->add_script('./include/js/like.js');
// ok message
if (isset($_GET['ok'])) {
switch ($_GET['ok']) {
case 'login':
if ($user->islogin) {
// only show welcome message if user is logged in
$greeting = cavern_greeting();
$view->show_message('inverted positive', "{$greeting}!我的朋友!");
}
break;
case 'reg':
$view->show_message('inverted primary', '註冊成功');
break;
case 'logout':
if (!$user->islogin) {
// only show message if user is logged out
$view->show_message('inverted info', '已登出');
}
break;
}
}
// error message
if (isset($_GET['err'])) {
switch ($_GET['err']) {
case 'account':
$view->show_message('inverted negative', '帳號不存在');
break;
case 'login':
$view->show_message('inverted negative', '帳號或密碼錯誤');
break;
case 'permission':
$view->show_message('warning', '帳號權限不足');
break;
case 'post':
$view->show_message('negative', '找不到文章');
break;
case 'nologin':
$view->show_message('warning', '請先登入');
break;
}
}
if (sizeOf($post_list) > 0) { ?>
<div class="ts active big text loader">載入中</div>
<div class="ts loading flatted borderless centered segment" id="cards">
<?php
foreach ($post_list as $_key => $article) {
?>
<div class="ts card" data-id="<?= $article->pid ?>">
<div class="content">
<div class="actions">
<div class="ts secondary buttons">
<button class="ts icon like button" data-id="<?= $article->pid ?>">
<i class="thumbs <?php if (!$article->is_like($user)) { echo "outline"; }?> up icon"></i> <?= $article->likes_count ?>
</button>
<a class="ts icon button" href="post.php?pid=<?= $article->pid ?>">
Read <i class="right arrow icon"></i>
</a>
</div>
</div>
<div class="header"><?= $article->title ?></div>
<div class="middoted meta">
<a href="user.php?username=<?= $article->author ?>"><?= $article->name ?></a>
<span><?= date('Y-m-d', strtotime($article->time)) ?></span>
</div>
<div class="description" id="markdown-post-<?= $article->pid ?>">
<div class="markdown">
<?= sumarize($article->content, 5) ?>
</div>
</div>
</div>
<div class="secondary right aligned extra content">
<i class="discussions icon"></i> <?= $article->comments_count ?> 則留言
</div>
</div>
<?php } ?>
</div>
<?php echo cavern_pages(@$_GET['page'], $all_posts_count, $blog['limit']);
} else {
$view->show_message('inverted info', '沒有文章,趕快去新增一個吧!');
}
$view->render();
?>

118
login.php Normal file
View File

@ -0,0 +1,118 @@
<?php
require_once('include/security.php');
require_once('connection/SQL.php');
require_once('config.php');
if (isset($_SESSION['cavern_username'])) {
if (isset($_GET['logout'])) {
if (validate_csrf()) {
cavern_logout();
header('axios-location: index.php?ok=logout');
} else {
http_response_code(403);
echo json_encode(array("status" => 'csrf'));
}
} else if (isset($_GET['next']) && $_GET['next'] == "admin") {
header("Location: ./admin/");
} else {
header('Location: index.php');
}
exit;
}
if ((isset($_POST['username'])) && (isset($_POST['password'])) && ($_POST['username']!='') && ($_POST['password']!='')) {
if (cavern_login($_POST['username'], $_POST['password']) == 1) {
if (isset($_POST['next']) && trim($_POST['next']) == "admin") {
header('Location: ./admin/');
} else if ((isset($_POST['next']) && filter_var($_POST['next'], FILTER_VALIDATE_URL)) || isset($_SERVER['HTTP_REFERER'])) {
// redirect to previous page before login
$next = (isset($_POST['next']) ? $_POST['next'] : $_SERVER['HTTP_REFERER']); // users login directly from navbar
$url_data = parse_url($next);
$len = strlen("index.php");
if (mb_substr($url_data['path'], -$len) === "index.php") {
// the user was viewing the index page, so we just redirect him to index page
header('Location: index.php?ok=login');
} else {
if (!isset($url_data['query'])) {
$url_data['query'] = "ok=login";
} else if (!strpos($url_data['query'], "ok=login")) {
// for those already have url queries, such as 'post.php?pid=1'
$url_data['query'] .= "&ok=login";
}
$url = "{$url_data['path']}?{$url_data['query']}";
header("Location: $url");
}
} else {
// previous page doesn't exist, so we just redirect to default page
header('Location: index.php?ok=login');
}
} else {
header('Location: index.php?err=login');
}
exit;
} else {
$admin = (isset($_GET['next']) && trim($_GET['next']) == "admin");
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://cdnjs.cloudflare.com/ajax/libs/tocas-ui/2.3.3/tocas.css" rel='stylesheet'>
<title>登入 | <?php echo $blog['name']; ?></title>
<style type="text/css">
html,body {
min-height: 100%;
margin: 0;
}
body {
background: linear-gradient(180deg, deepskyblue 5%, aqua);
}
.ts.narrow.container {
padding: 4em 0;
}
.segment {
max-width: 300px;
}
/* admin style */
body.admin {
background: linear-gradient(0deg, #1CB5E0, #000046);
}
body.admin .ts.header, body.admin .ts.header .sub.header{
color: white;
}
.inverted .ts.form .field > label {
color: #EFEFEF;
}
</style>
</head>
<body <?= ($admin ? 'class="admin"' : "") ?>>
<div class="ts narrow container">
<h1 class="ts center aligned header">
<?= $blog['name'] ?>
<div class="sub header"><?= ($admin ? "安全門" : "傳送門") ?></div>
</h1>
<div class="ts centered <?= ($admin ? "inverted" : "secondary") ?> segment">
<form class="ts form" method="POST" action="login.php">
<div class="field">
<label>帳號</label>
<input type="text" name="username">
</div>
<div class="field">
<label>密碼</label>
<input type="password" name="password">
</div>
<input type="hidden" name="next" value="<?= ($admin ? "admin" : @$_SERVER['HTTP_REFERER']); ?>">
<div class="ts separated vertical fluid buttons">
<input type="submit" class="ts positive button" value="登入">
<a href="account.php?new" class="ts button">註冊</a>
</div>
</form>
</div>
</div>
</body>
</html>
<?php }
?>

57
notification.php Normal file
View File

@ -0,0 +1,57 @@
<?php
require_once('connection/SQL.php');
require_once('config.php');
require_once('include/view.php');
if (isset($_SESSION['cavern_username'])) {
$view = new View('theme/default.html', 'theme/nav/util.php', 'theme/sidebar.php', $blog['name'], "通知");
$view->add_script_source("ts('.ts.dropdown:not(.basic)').dropdown();");
$view->add_script("./include/js/security.js");
$notice_list = cavern_query_result("SELECT * FROM `notification` WHERE `username` = '%s' ORDER BY `time` DESC", array($_SESSION['cavern_username']));
if ($notice_list['num_rows'] > 0) {
$regex = array(
"username" => "/\{([^\{\}]+)\}@(\w+)/",
"url" => "/\[([^\[\[]*)\]/"
);
?>
<div class="ts big dividing header">通知 <span class="notification description">#僅顯示最近 100 則通知</span></div>
<div class="table wrapper">
<table class="ts sortable celled striped table">
<thead>
<tr>
<th>內容</th>
<th>日期</th>
</tr>
</thead>
<tbody>
<?php
do {
$message = $notice_list['row']['message'];
$time = $notice_list['row']['time'];
$url = $notice_list['row']['url'];
$message = preg_replace($regex["username"], '<a href="user.php?username=$2">$1</a>', $message);
$message = preg_replace($regex["url"], "<a href=\"${url}\">$1</a>", $message);
?>
<tr>
<td><?= $message ?></td>
<td class="collapsing"><?= $time ?></td>
</tr>
<?php } while ($notice_list['row'] = $notice_list['query']->fetch_assoc()); ?>
</tbody>
</table>
</div>
<?php
} else {
$view->show_message('inverted info', '目前沒有通知。');
}
$view->render();
} else {
http_response_code(204);
header('Location: index.php?err=nologin');
exit;
}
?>

460
post.php Normal file
View File

@ -0,0 +1,460 @@
<?php
require_once('connection/SQL.php');
require_once('config.php');
set_include_path('include/');
require_once('view.php');
require_once('user.php');
require_once('article.php');
require_once('security.php');
require_once('notification.php');
$user = validate_user();
if (!$user->valid) {
http_response_code(403);
header("Location: ../index.php?err=account");
exit;
}
if ($user->islogin && isset($_POST['pid']) && isset($_POST['title']) && isset($_POST['content'])) {
if ($user->level < 1 || $user->muted == 1) {
http_response_code(403);
header('axios-location: post.php?err=level');
exit;
}
if (!validate_csrf()) {
http_response_code(403);
header('axios-location: post.php?err=level');
}
if ($_POST['pid'] == "-1") {
// new post
if (trim($_POST['content']) == "") {
http_response_code(400);
header('axios-location: post.php?err=empty');
exit;
}
if (trim($_POST['title']) == "") {
$_POST['title'] = "(無標題)";
}
$current = date('Y-m-d H:i:s');
$SQL->query("INSERT INTO `post` (`title`, `content`, `time`, `username`) VALUES ('%s', '%s', '%s', '%s')", array(htmlspecialchars($_POST['title']), htmlspecialchars($_POST['content']), $current, $user->username));
$pid = $SQL->insert_id();
// notify tagged user
// the user who tag himself is unnecessary to notify
$username_list = parse_user_tag($_POST['content']);
foreach ($username_list as $key => $id) {
if ($id == $user->username) continue;
cavern_notify_user($id, "{{$user->name}}@{$user->username} 在 [{$_POST['title']}] 中提到了你", "post.php?pid=$pid");
}
http_response_code(201); // 201 Created
header('axios-location: post.php?pid='.$pid);
exit;
} else {
// edit old post
$pid = abs($_POST['pid']);
try {
$post = new Article($pid);
} catch (NoPostException $e) {
// post not found
http_response_code(404);
header("axios-location: index.php?err=post");
exit;
}
if ($post->author !== $user->username && $user->level < 8) {
http_response_code(403);
header('axios-location: post.php?err=edit');
exit;
}
if (trim($_POST['content']) == "") {
http_response_code(400);
header('axios-location: post.php?err=empty');
exit;
}
if (trim($_POST['title']) == "") {
$_POST['title'] = "(無標題)";
}
$post->modify($user, "title", htmlspecialchars($_POST['title']));
$post->modify($user, "content", htmlspecialchars($_POST['content']));
$post->save();
header('axios-location: post.php?pid='.$_POST['pid']);
exit;
}
}
if ($user->islogin && isset($_GET['del']) && trim($_GET['del']) != '') {
if (!validate_csrf()) {
http_response_code(403);
header('axios-location: post.php?err=level');
exit;
}
try {
$post = new Article(intval($_GET['del']));
} catch (NoPostException $e) {
http_response_code(404);
header('axios-location: index.php?err=post');
exit;
}
if ($post->author !== $user->username && $user->level < 8) {
http_response_code(403);
header('axios-location: post.php?err=del');
exit;
} else {
$SQL->query("DELETE FROM `post` WHERE `pid`='%d' AND `username` = '%s'", array($_GET['del'], $_SESSION['cavern_username']));
http_response_code(204);
header('axios-location: post.php?ok=del');
exit;
}
} else if (!$user->islogin && isset($_GET['del'])) {
http_response_code(204);
header('axios-location: index.php?err=nologin');
exit;
}
// View
if (isset($_GET['pid'])) {
$pid = abs($_GET['pid']);
try {
$post = new Article($pid);
} catch (NoPostException $e) {
http_response_code(404);
header('Location: index.php?err=post');
exit;
}
if ($user->islogin) {
$view = new View('theme/default.html', 'theme/nav/util.php', 'theme/sidebar.php', $blog['name'], $post->title);
$view->add_script_source("ts('.ts.dropdown:not(.basic)').dropdown();");
$owner_view = ($post->author === $user->username);
} else {
$view = new View('theme/default.html', 'theme/nav/default.html', 'theme/sidebar.php', $blog['name'], $post->title);
$owner_view = FALSE;
}
$view->add_script("https://unpkg.com/load-js@1.2.0");
$view->add_script("./include/js/lib/editormd.js");
$view->add_script("./include/js/lib/css.min.js");
$view->add_script("./include/js/security.js");
$view->add_script("./include/js/markdown.js");
$view->add_script("./include/js/comment.js");
$view->add_script("./include/js/post.js");
$view->add_script("./include/js/like.js");
$view->add_script_source("ts('.ts.tabbed.menu .item').tab();");
if (isset($_GET['ok'])) {
if ($_GET['ok'] == "login" && $user->islogin) {
$greeting = cavern_greeting();
$view->show_message("inverted positive", "{$greeting}!我的朋友!");
}
}
?>
<div class="ts<?php echo ($owner_view ? " stackable " : " "); ?>grid">
<div class="stretched column" id="header">
<h2 class="ts header">
<?= $post->title ?>
<div class="sub header"><a href="user.php?username=<?= $post->author ?>"><?= $post->name ?></a></div>
</h2>
</div>
<div class="action column">
<div class="ts secondary icon buttons">
<button class="ts secondary icon like button" data-id="<?= $pid ?>">
<i class="thumbs <?php if (!$post->is_like($user)) {echo "outline";}?> up icon"></i> <?= $post->likes_count ?>
</button>
<?php
if ($owner_view) { ?>
<a class="ts secondary icon button" href="post.php?edit=<?= $pid ?>">
<i class="edit icon"></i>
</a>
<a class="ts secondary icon delete button" href="post.php?del=<?= $pid ?>">
<i class="trash icon"></i>
</a>
<?php } ?>
</div>
</div>
</div>
<div class="ts segments">
<div class="ts flatted segment" id="post" data-id="<?= $pid ?>">
<div class="markdown"><?= $post->content ?></div>
</div>
<div class="ts right aligned tertiary segment">
<i class="clock icon"></i><?= $post->time ?>
</div>
</div>
<div class="comment header">
<div class="ts grid">
<div class="stretched header column">
<div class="ts big header">留言</div>
</div>
<div class="column">
<span class="fetch time">Last fetch: --:--</span>&nbsp;
<div class="ts active inline loader"></div>
<button class="ts fetch icon button">
<i class="refresh icon"></i>
</button>
</div>
</div>
<div class="ts comment divider"></div>
</div>
<div class="ts comments">
<div class="ts borderless flatted no-comment segment">現在還沒有留言!</div>
</div>
<div class="ts segments" id="comment">
<div class="ts fitted secondary segment">
<div class="ts tabbed menu">
<a class="active item" data-tab="textarea">Write</a>
<a class="item" data-tab="preview">Preview</a>
</div>
</div>
<div class="ts clearing active tab segment" data-tab="textarea">
<?php if ($user->islogin) {
if ($user->muted) {
$disabled = " disabled";
$placeholder = "你被禁言了。";
$button_text = "你被禁言了";
} else {
$disabled = "";
$placeholder = "留言,然後開戰。";
$button_text = "留言";
}
} else {
$disabled = " disabled";
$placeholder = "請先登入";
$button_text = "留言";
} ?>
<div class="ts<?= $disabled ?> fluid input">
<textarea placeholder="<?= $placeholder ?>" rows="5" autocomplete="off"<?= $disabled ?>></textarea>
</div>
<div class="ts<?= $disabled ?> right floated separated action buttons">
<button class="ts positive submit button"><?= $button_text ?></button>
</div>
</div>
<div class="ts tab segment" id="preview" data-tab="preview"></div>
</div>
<?php $view->render();
} else if (isset($_GET['new']) || isset($_GET['edit'])) {
// New or Edit
if (!$user->islogin) {
header('Location: index.php?err=nologin');
exit;
}
if ($user->level < 1 || $user->muted) {
header('Location: post.php?err=level');
exit;
}
if (isset($_GET['new'])) {
$mode = "new";
$pid = -1;
$title = "";
$content = "";
} else if (isset($_GET['edit'])) {
$mode = "edit";
$pid = abs($_GET['edit']);
try {
$post = new Article($pid);
} catch (NoPostException $e) {
http_response_code(404);
header('Location: index.php?err=post');
exit;
}
if ($post->author != $user->username) {
http_response_code(403);
header('Location: post.php?err=edit');
exit;
}
}
$title = $post->title;
$content = $post->content;
$view = new View('theme/default.html', 'theme/nav/util.php', 'theme/sidebar.php', $blog['name'], ($title == "" ? "文章" : $title));
$view->add_script_source("ts('.ts.dropdown:not(.basic)').dropdown();");
$view->add_script("./include/js/lib/editormd.js");
$view->add_script("./include/js/lib/zh-tw.js");
$view->add_script("./include/js/lib/css.min.js");
$view->add_script("./include/js/security.js");
$view->add_script("./include/js/edit.js");
?>
<form action="post.php" method="POST" name="edit" id="edit" autocomplete="off"> <!-- prevent Firefox from autocompleting -->
<div class="ts stackable grid">
<div class="stretched column">
<div class="ts huge fluid underlined input">
<input placeholder="標題" name="title" value="<?= $title ?>">
</div>
</div>
<div class="action column">
<div class="ts buttons">
<button class="ts positive button">發布</button>
<?php if ($mode == "edit") { ?>
<a href="post.php?del=<?= $pid ?>" class="ts negative delete button">刪除</a>
<?php } ?>
<a href="index.php" class="ts button">取消</a>
</div>
</div>
</div>
<div id="markdownEditor">
<textarea id="markdown" name="content" stlye="display: none;" autocomplete="off" autocorrect="off" spellcheck="false"><?= $content ?></textarea>
</div>
<input type="hidden" name="pid" id="pid" value="<?= $pid ?>">
</form>
<?php $view->render();
} else {
// List all
if (!$user->islogin && (!isset($_GET['username']) || trim($_GET['username']) == "")) {
http_response_code(403);
header('Location: index.php?err=nologin');
exit;
}
if (isset($_GET['username']) && trim($_GET['username']) != "") {
$username = trim($_GET['username']);
try {
$target_user = new User($username);
} catch (NoUserException $e) {
http_response_code(404);
header('Location: user.php?err=no');
exit;
}
$post_list = article_list(cavern_query_result(
"SELECT * FROM `post` WHERE `username`='%s' ORDER BY `time`",
array($username))
);
} else if ($user->islogin) {
$username = $user->username;
$post_list = article_list(cavern_query_result(
"SELECT * FROM `post` WHERE `username`='%s' ORDER BY `time`",
array($username))
);
}
$owner_view = ($user->islogin && $username === $user->username);
if ($user->islogin) {
$view = new View('theme/default.html','theme/nav/util.php', 'theme/sidebar.php', $blog['name'], "文章");
$view->add_script_source("$('tbody').on('click', 'a.negative.button', function(e) {
e.preventDefault();
let el = e.currentTarget;
let href = el.getAttribute('href');
swal({
type: 'question',
title: '確定要刪除嗎?',
showCancelButton: true,
confirmButtonText: '確定',
cancelButtonText: '取消',
}).then((result) => {
if (result.value) { // confirm
axios.request({
method: 'GET',
maxRedirects: 0,
url: href
}).then(function (res) {
location.href = res.headers['axios-location'];
});
}
});
});");
} else {
$view = new View('theme/default.html','theme/nav/default.html', 'theme/sidebar.php', $blog['name'], "文章");
}
$view->add_script("./include/js/security.js");
$view->add_script_source("ts('.ts.dropdown').dropdown();\nts('.ts.sortable.table').tablesort();");
if (isset($_GET['ok'])) {
if ($_GET['ok'] == "del") {
$view->show_message("inverted positive", "刪除成功");
} else if ($_GET['ok'] == "login" && $user->islogin) {
$greeting = cavern_greeting();
$view->show_message("inverted positive", "{$greeting}!我的朋友!");
}
}
if (isset($_GET['err'])) {
switch ($_GET['err']) {
case 'del':
$view->show_message("inverted negative", "刪除失敗");
break;
case 'edit':
$view->show_message("inverted negative", "編輯失敗");
break;
case 'empty':
$view->show_message("warning", "文章內容不能為空!");
break;
case 'level':
$view->show_message("inverted negative", "你沒有權限發文!");
break;
}
}
?>
<div class="ts big dividing header">
文章
<?php if (!$owner_view) { // List other one's post ?>
<div class="sub header"><a href="user.php?username=<?= $username ?>"><?= $target_user->name ?></a></div>
<?php } ?>
</div>
<div class="table wrapper">
<table class="ts sortable celled striped table">
<thead>
<tr>
<th>標題</th>
<th></th>
<th>留言</th>
<th>日期</th>
<?php if ($owner_view) { // Only owner could manage post ?>
<th>管理</th>
<?php } ?>
</tr>
</thead>
<tbody>
<?php
if (sizeof($post_list) > 0) {
foreach ($post_list as $key => $article) {
?>
<tr>
<td><a href="post.php?pid=<?= $article->pid ?>"><?= $article->title ?></a></td>
<td class="center aligned collapsing"><?= $article->likes_count ?></td>
<td class="center aligned collapsing"><?= $article->comments_count ?></td>
<td class="collapsing"><?= $article->time ?></td>
<?php if ($owner_view) { // Only owner could manage post ?>
<td class="right aligned collapsing">
<a class="ts circular icon button" href="post.php?edit=<?= $article->pid ?>"><i class="pencil icon"></i></a>
<a class="ts negative circular icon button" href="post.php?del=<?= $article->pid ?>"><i class="trash icon"></i></a>
</td>
<?php } ?>
</tr>
<?php }
} else { ?>
<tr>
<td colspan="<?php echo ($owner_view ? 5 : 4); ?>">沒有文章</td>
<tr>
<?php } ?>
</tbody>
</table>
</div>
<?php
$view->render();
}
?>

51
theme/default.html Normal file
View File

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{part} | {title}</title>
<!-- Tocas UICSS 與元件 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tocas-ui/2.3.3/tocas.css">
<!-- Tocas JS模塊與 JavaScript 函式 -->
<script src="include/js/lib/tocas.js"></script>
<!-- jQuery -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@7.26.11/dist/sweetalert2.all.min.js"></script>
<link rel="stylesheet" href="https://pandao.github.io/editor.md/css/editormd.css" />
<link rel="stylesheet" href="include/css/cavern.css">
</head>
<body>
{nav}
<div class="ts large vertically padded fluid heading slate">
<div class="ts narrow container">
<div class="header">{title}</div>
<div class="description">Welcome to {title}</div>
</div>
</div>
<div class="ts narrow container" id="main">
<div class="ts stackable grid">
<div class="twelve wide column" id="content">
{message}
{content}
</div>
<div class="four wide column" id="sidebar">
{sidebar}
</div>
</div>
</div>
<footer>
<div class="ts divider"></div>
<div class="ts center aligned basic segment">
Powered by <a href="https://app.stoneapp.tech/#cavern">Cavern</a>
</div>
</footer>
<!-- Anchor -->
<div class="ts bottom right snackbar">
<div class="content"></div>
<a class="action"></a>
</div>
<!-- Scripts -->
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
{script}
</body>
</html>

16
theme/nav/default.html Normal file
View File

@ -0,0 +1,16 @@
<div class="ts top attached pointing secondary large menu" id="menu">
<div class="ts narrow container">
<a href="index.php" class="active item">首頁</a>
<form class="right fitted item" action="login.php" method="POST" name="login">
<div class="tablet or large device only">
<div class="ts small underlined input">
<input placeholder="Username" type="text" name="username">
<input placeholder="Password" type="password" name="password">
</div>
</div>
<button class="ts small primary login icon button">
<i class="sign in icon"></i>
</button>
</form>
</div>
</div>

28
theme/nav/util.php Normal file
View File

@ -0,0 +1,28 @@
<?php
$self = @end(explode('/',$_SERVER['PHP_SELF']));
?>
<div class="ts top attached pointing secondary large menu" id="menu">
<div class="ts narrow container">
<a href="index.php" class="<?php if ($self == 'index.php') { echo "active "; } ?>item">首頁</a>
<div class="ts <?php if ($self == 'post.php') { echo "active "; } ?>dropdown item">
<div class="text">文章</div>
<div class="menu">
<a href="post.php?new" class="item">新增</a>
<a href="post.php" class="item">列表</a>
</div>
</div>
<a href="account.php" class="<?php if ($self == 'account.php') { echo "active "; } ?>item">帳號</a>
<div class="right menu">
<a href="#" class="notification icon item"><i class="bell outline icon"></i></a>
<a href="#" class="item" id="logout">登出</a>
</div>
</div>
</div>
<div class="ts narrow container" id="notification-wrapper">
<div class="notification container">
<div class="ts borderless top attached segment">通知</div>
<div class="ts relaxed divided feed"></div>
<a class="ts bottom attached fluid button" href="notification.php">看所有通知</a>
</div>
</div>
<script src="include/js/notification.js"></script>

40
theme/sidebar.php Normal file
View File

@ -0,0 +1,40 @@
<?php
require_once('connection/SQL.php');
require_once('include/user.php');
require_once('config.php');
$user = validate_user();
if(!$user->islogin){ ?>
<div class="ts basic center aligned padded segment">
登入或是<a href="account.php?new">註冊</a>
</div>
<?php } else { ?>
<a class="ts center aligned big header" data-username="<?= $user->username ?>" href="user.php?username=<?= $user->username ?>">
<img class="ts circular avatar image" src="https://www.gravatar.com/avatar/<?= md5(strtolower($user->email)) ?>?d=https%3A%2F%2Ftocas-ui.com%2Fassets%2Fimg%2F5e5e3a6.png&s=150"> <?= $user->name ?>
<?php if ($user->muted) { ?>
<div class="negative sub header">
<i class="ban icon"></i>你已被禁言!
</div>
<?php } ?>
</a>
<?php } ?>
<div class="ts fluid icon input">
<input type="text" placeholder="在這搜尋人、事、物">
<i class="inverted circular search link icon"></i>
</div>
<!-- Segment 1 -->
<div class="ts tertiary top attached center aligned segment">名稱</div>
<div class="ts bottom attached segment">
<p>項目</p>
<p>項目</p>
<p>項目</p>
</div>
<!-- Segment 2 -->
<div class="ts tertiary top attached center aligned segment">名稱</div>
<div class="ts bottom attached segment">
<p>項目</p>
<p>項目</p>
<p>項目</p>
</div>

BIN
theme/thinking.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

108
user.php Normal file
View File

@ -0,0 +1,108 @@
<?php
require_once('connection/SQL.php');
require_once('config.php');
require_once('include/view.php');
if (isset($_GET['username']) && trim($_GET['username']) != "") {
$username = trim($_GET['username']);
$result = cavern_query_result("SELECT * FROM `user` WHERE `username`='%s'", array($username));
if ($result['num_rows'] > 0) {
$name = $result['row']['name'];
$level = $result['row']['level'];
$email = md5(strtolower($result['row']['email']));
$role = cavern_level_to_role($level);
$posts = cavern_query_result("SELECT * FROM `post` WHERE `username`='%s'", array($username));
$posts_count = ($posts['num_rows'] > 0 ? $posts['num_rows'] : 0);
} else {
http_response_code(404);
header('Location: user.php?err=no');
exit;
}
if (isset($_SESSION['cavern_username'])) {
$view = new View('theme/default.html', 'theme/nav/util.php', 'theme/sidebar.php', $blog['name'], $name);
$view->add_script_source("ts('.ts.dropdown').dropdown();");
} else {
$view = new View('theme/default.html', 'theme/nav/default.html', 'theme/sidebar.php', $blog['name'], $name);
}
$view->add_script("./include/js/security.js");
if (isset($_GET['err'])) {
if ($_GET['err'] == "no") {
$view->show_message('negative', "找不到使用者");
$view->render();
exit;
}
}
?>
<div class="ts big dividing header"><?= $name ?> 的個人資料</div>
<div class="ts stackable grid">
<div class="column">
<div class="ts center aligned flatted borderless segment">
<img src="https://www.gravatar.com/avatar/<?= $email ?>?d=https%3A%2F%2Ftocas-ui.com%2Fassets%2Fimg%2F5e5e3a6.png&s=500" class="ts rounded image" id="avatar">
</div>
</div>
<div class="stretched column">
<div class="table wrapper">
<table class="ts borderless three column table">
<thead>
<tr>
<th colspan="2">基本資料</th>
</tr>
</thead>
<tbody>
<tr>
<td>使用者名稱</td>
<td><?= $username ?></td>
</tr>
<tr>
<td>暱稱</td>
<td><?= $name ?></td>
</tr>
<tr>
<td>權限</td>
<td><?= $role ?></td>
</tr>
</tbody>
</table>
<table class="ts borderless two column table">
<thead>
<tr>
<th colspan="2">統計</th>
</tr>
</thead>
<tbody>
<tr>
<td>文章數</td>
<td><?= $posts_count ?></td>
</tr>
<tr>
<td colspan="2" class="right aligned"><a href="post.php?username=<?= $username ?>">看他的文章 <i class="hand outline right icon"></i></a></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<?php $view->render();
} else {
if (isset($_GET['err'])) {
if (isset($_SESSION['cavern_username'])) {
$view = new View('theme/default.html', 'theme/nav/util.php', 'theme/sidebar.php', $blog['name'], "使用者");
$view->add_script_source("ts('.ts.dropdown').dropdown();");
} else {
$view = new View('theme/default.html', 'theme/nav/default.html', 'theme/sidebar.php', $blog['name'], "使用者");
}
$view->add_script("./include/js/security.js");
if ($_GET['err'] == "no") {
$view->show_message('negative', "找不到使用者");
$view->render();
exit;
}
} else {
header('Location: user.php?err=no');
exit;
}
}
?>