chore: 🚀 init

This commit is contained in:
draconigen 2025-02-19 22:17:58 +01:00
parent 09f2e6b055
commit a4dceb0da5
12 changed files with 502 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
telegram.config.php
uppies/*
!uppies/.gitkeep
storage.db

38
css/style.css Normal file
View File

@ -0,0 +1,38 @@
* {
box-sizing: border-box;
}
html {
background-color: #222;
}
body {
margin: 20px;
}
body, h1, h2, h3 {
color: #fff;
}
header, main, footer {
max-width: 1000px;
margin: 0 auto;
}
#bookmark {
background-color: #333;
}
.zfont {
font-size: 0;
}
#img {
z-index: -1;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: .5;
background-image: url(../img/lorem.png);
background-repeat: no-repeat;
background-size: contain;
background-position: top right;
}

1
css/uikit.min.css vendored Normal file

File diff suppressed because one or more lines are too long

BIN
favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
img/lorem.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
img/ogp.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

263
index.php Normal file
View File

@ -0,0 +1,263 @@
<?php declare(strict_types=1);
include_once('src/storage.php');
include_once('src/telegram.php');
if (empty($_GET['id'])) {
$newId = bin2hex(random_bytes(16));
header("Location: ?id=$newId");
exit;
}
$id = htmlspecialchars($_GET['id']);
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$bookmark = $protocol . '://' . $_SERVER['SERVER_NAME'] . $_SERVER['REQUEST_URI'];
$alerts = [
'primary' => [],
'success' => [],
'warning' => [],
'danger' => []
];
$data = ['img' => '', 'name' => '', 'url' => '', 'desc' => ''];
try {
$data = Storage::get($id);
}
catch(Exception $ex) {
$alerts['danger'][] = '❌ Error reading database';
}
$img = $data['img'];
// echo '<pre>'; print_r($img); echo '</pre>';
$name = $data['name'];
$url = $data['url'];
$desc = $data['desc'];
if ($_SERVER["REQUEST_METHOD"] === 'POST' && isset($_GET['upload'])) {
http_response_code(202);
$targetDir = "uppies/";
$file = $_FILES["files"];
$targetFile = $targetDir . basename($file["name"][0]);
$imageFileType = strtolower(pathinfo($targetFile, PATHINFO_EXTENSION));
// Create the uploads directory if it doesn't exist
if (!is_dir($targetDir)) {
mkdir($targetDir, 0775, true);
}
// Check if the file is actually an image
$check = getimagesize($file["tmp_name"][0]);
if ($check === false) {
exit("❌ Error: File is not a valid image.");
}
// Allow only specific image file formats
$allowedTypes = ["jpg", "jpeg", "png", "gif"];
if (!in_array($imageFileType, $allowedTypes)) {
exit("❌ Error: Only JPG, JPEG, PNG and GIF files allowed.");
}
// Limit file size (e.g., 5MB)
if ($file["size"][0] > 100 * 1024 * 1024) {
exit("❌ Error: File too large (max. 100 MB)");
}
// generate destination file name
$finalPath = $targetDir . $id . '.' . $imageFileType;
// Move uploaded file to the target directory
if (move_uploaded_file($file["tmp_name"][0], $finalPath)) {
try {
Storage::set_img($id, $id . '.' . $imageFileType);
}
catch(Exception $ex) {
exit("❌ Database error.");
}
http_response_code(200);
exit($finalPath);
}
exit("❌ General error saving the file.");
}
else if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['desc']) && isset($_POST['name']) && isset($_POST['url'])) {
$name = htmlspecialchars(trim($_POST['name']));
$url = htmlspecialchars(trim($_POST['url']));
$desc = htmlspecialchars(trim($_POST['desc']));
if (!empty($name) && !empty($desc) && !empty($url)) {
try {
Storage::set_data($id, $name, $url, $desc);
Telegram::report("EF Conbook Artist Credits Submission\nname: $name\nurl: $url\ntext:\n$desc");
$alerts['success'][] = '✅ Entry saved';
}
catch(Exception $ex) {
$alerts['danger'][] = $ex;
}
}
}
?>
<!DOCTYPE html>
<html prefix="og: http://ogp.me/ns#" lang="en">
<head>
<title>Artist Credits</title>
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Submit Artist Credits to the Eurofurence Conbook Team" />
<meta name="keywords" content="eurofurence, Conbook, artist credits" />
<meta name="robots" content="index, follow, noodp" />
<meta name="author" content="The Eurofurence Conbook Team" />
<meta name="rating" content="general" />
<link rel="shortcut icon" href="favicon.png">
<meta property="og:image" content="img/ogp.jpg" />
<meta property="og:image:secure_url" content="img/ogp.jpg" />
<meta property="og:image:type" content="image/jpeg" />
<meta property="og:image:width" content="344" />
<meta property="og:image:height" content="247" />
<meta property="og:image:alt" content="A dog on a cloud." />
<meta property="og:title" content="Conbook Art Credits" />
<meta property="og:description" content="Submit Artist Credits to the Eurofurence Conbook Team" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://dogpixels.net/ef/conbook-artist-credits" />
<meta property="og:site_name" content="Conbook Art Credits" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Conbook Art Credits" />
<meta name="twitter:description" content="Submit Artist Credits to the Eurofurence Conbook Team" />
<meta name="twitter:image" content="img/ogp.jpg" />
<link rel="stylesheet" href="css/uikit.min.css" type="text/css" />
<link rel="stylesheet" href="css/style.css" type="text/css" />
<script src="js/uikit.min.js"></script>
<script src="js/uikit-icons.min.js"></script>
</head>
<body>
<header>
<h1>Conbook Artist Credit Form</h1>
<p>
Thank you for submitting art to the Eurofurence Conbook. In the event of your art making an appearance in the book, your are eligible to an entry in the artist credits.<br />
Edit your entry below before <strong>the end of July</strong> to make sure you are credited appropriately.
</p>
<p>Bookmark this page or save the following url to be able to edit your entry later:</p>
<div class="uk-width-1-1 uk-grid-collapse" uk-grid>
<div class="uk-width-3-4@m">
<input type="text" id="bookmark" disabled value="<?= $bookmark ?>" class="uk-input" />
</div>
<div class="uk-width-1-4@m">
<button type="button" id="copyUrl" class="uk-button uk-button-primary uk-width-1-1" title="✅ Copied">Copy to Clipboard</button>
</div>
</div>
</header>
<main class="uk-margin">
<hr />
<p>Please make sure your profile image is large enough for print. Don't worry about cropping or aspect ratio, it will be adjusted into the layout by us.</p>
<section class="uk-child-width-1-2@m" uk-grid>
<div>
<div class="js-upload uk-placeholder uk-margin-top uk-position-relative">
<div id="img"></div>
<!-- <img id="img" src="" alt="No image uploaded yet" /> -->
<span uk-icon="icon: cloud-upload"></span>
<span class="uk-text-middle">Upload Your Profile Image<br />PNG, JPG or GIF<br />min. 500 x 500 @ 300 dpi<br /></span>
<div uk-form-custom>
<input type="file">
<span class="uk-link">SELECT FILE</span>
</div>
</div>
<progress id="js-progressbar" class="uk-progress" value="0" max="100" hidden></progress>
</div>
<div>
<form action="" method="POST">
<input type="text" name="name" id="name" maxlength="80" value="<?= $name ?>" placeholder="Your nickname (max. 80 characters)" class="uk-input uk-margin-top" />
<input type="text" name="url" id="url" maxlength="256" value="<?= $url ?>" placeholder="Your gallery link / homepage (max. 256 characters)" class="uk-input uk-margin-top" />
<input type="text" name="desc" id="desc" maxlength="400" value="<?= $desc ?>" placeholder="Your description (max. 400 characters)" class="uk-input uk-margin-top" />
<input type="submit" value="Save" class="uk-button uk-button-primary uk-margin-top" />
</form>
</div>
</section>
</main>
<footer>
<hr />
<p>If you need assistance, please contact <a href="https://help.eurofurence.org/contact/conbook" target="_blank">The Conbook Department</a>.</p>
</footer>
<script>
document.getElementById('copyUrl').addEventListener('click', (e) => {
var furl = document.getElementById('bookmark');
furl.setSelectionRange(0, furl.value.length);
navigator.clipboard.writeText(furl.value);
furl.setSelectionRange(0, 0);
e.target.innerText = "✅ copied";
// setTimeout(() => {e.target.innerText = "Copy to Clipboard"}, 3000);
});
</script>
<script>
<?php
foreach ($alerts as $status => $msgs) {
foreach ($msgs as $msg) {
echo 'UIkit.notification("' . $msg . '", "' . $status . '");';
}
}
?>
</script>
<?php if ($img !== '') { ?>
<script>
document.getElementById('img').style.backgroundImage = 'url(uppies/<?= $img ?>?' + new Date().getTime() + ')';
</script>
<?php } ?>
<script>
var bar = document.getElementById('js-progressbar');
UIkit.upload('.js-upload', {
url: '<?= $bookmark ?>&upload',
loadStart: function (e) {
bar.removeAttribute('hidden');
bar.max = e.total;
bar.value = e.loaded;
},
progress: function (e) {
bar.max = e.total;
bar.value = e.loaded;
},
loadEnd: function (e) {
bar.max = e.total;
bar.value = e.loaded;
},
completeAll: function (r) {
if (r.status == 200) {
console.log(r);
document.getElementById('img').style.backgroundImage = 'url(' + r.responseText + '?' + new Date().getTime() + ')';
UIkit.notification('✅ Image saved', 'success');
}
else {
UIkit.notification(r.responseText, 'danger');
}
setTimeout(function () {
bar.setAttribute('hidden', 'hidden');
}, 1000);
}
});
</script>
</body>
</html>

1
js/uikit-icons.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
js/uikit.min.js vendored Normal file

File diff suppressed because one or more lines are too long

144
src/storage.php Normal file
View File

@ -0,0 +1,144 @@
<?php declare(strict_types=1);
/**
* Persistent Key-Value Storage 1.0
* (c) 2025 draconigen@dogpixels.net
* AGPL 3.0, see https://www.gnu.org/licenses/agpl-3.0.de.html
* Provided "as is", without warranty of any kind.
*/
define('STORAGE_FILE', 'storage.db');
define('STORAGE_ENCRYPTION_KEY', ''); // empty disables encryption
/**
* Static Class for persistent Key-Value storage.
* Creates a database file (default: storage.sqlite) next to the script.
*/
class Storage {
static private function init(): Sqlite3 {
$flagInitDatabase = !file_exists(STORAGE_FILE);
$db = new SQLite3(STORAGE_FILE, SQLITE3_OPEN_READWRITE | SQLITE3_OPEN_CREATE, STORAGE_ENCRYPTION_KEY);
if ($flagInitDatabase) {
$db->exec("CREATE TABLE cache (
id TEXT NOT NULL UNIQUE,
mod DATATIME DEFAULT (DATETIME('now', 'localtime')),
img TEXT,
name TEXT,
url TEXT,
desc TEXT,
PRIMARY KEY(id)
);");
}
return $db;
}
/**
* Retrieve a single entry from database.
* @param string $id Key of the entry to retrieve.
* @return array|bool JSON-serializable array on success, otherwise false
*/
static public function get(string $id): array | bool {
$db = Storage::init();
$stmt = $db->prepare("SELECT name, url, desc, img FROM cache WHERE id=?;");
if (!$stmt || !$stmt->bindValue(1, $id, SQLITE3_TEXT)) {
throw new Exception($db->lastErrorMsg());
return false;
}
$cur = $stmt->execute();
if (!$cur) {
throw new Exception($db->lastErrorMsg());
return false;
}
while ($row = $cur->fetchArray()) {
return ['img' => $row['img'], 'name' => $row['name'], 'url' => $row['url'], 'desc' => $row['desc']];
}
return ['img' => '', 'name' => '', 'url' => '', 'desc' => ''];
}
/**
* Retrieves all entries from database.
* @return array Assoc array of id => [data]
*/
static public function getAll(): array {
$db = Storage::init();
$ret = [];
$stmt = $db->prepare("SELECT id, img, name, url, desc FROM cache;");
if (!$stmt) {
throw new Exception($db->lastErrorMsg());
return $ret;
}
$cur = $stmt->execute();
if (!$cur) {
throw new Exception($db->lastErrorMsg());
return $ret;
}
while ($row = $cur->fetchArray()) {
$ret[$row['id']] = [$row['img'], $row['name'], $row['url'], $row['desc']];
}
return $ret;
}
/**
* Inserts or updates data
* @param string $id Key of the entry to insert/update.
* @param array $data JSON-serializable data array to write to database.
* @return bool Success indicator.
*/
static public function set_data(string $id, string $name, string $url, string $desc): bool {
$db = Storage::init();
$stmt = $db->prepare("INSERT INTO cache (id, name, url, desc) VALUES(?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name=excluded.name, url=excluded.url, desc=excluded.desc, mod=excluded.mod;");
if (!$stmt || !$stmt->bindValue(1, $id, SQLITE3_TEXT) || !$stmt->bindValue(2, $name, SQLITE3_TEXT) || !$stmt->bindValue(3, $url, SQLITE3_TEXT)|| !$stmt->bindValue(4, $desc, SQLITE3_TEXT) || !$stmt->execute()) {
throw new Exception($db->lastErrorMsg());
return false;
}
return true;
}
static public function set_img(string $id, string $img): bool {
$db = Storage::init();
$stmt = $db->prepare("INSERT INTO cache (id, img) VALUES(?, ?) ON CONFLICT(id) DO UPDATE SET img=excluded.img, mod=excluded.mod;");
if (!$stmt || !$stmt->bindValue(1, $id, SQLITE3_TEXT) || !$stmt->bindValue(2, $img, SQLITE3_TEXT) || !$stmt->execute()) {
throw new Exception($db->lastErrorMsg());
return false;
}
return true;
}
/**
* Deletes a single entry.
* @param string $id Key of entry to delete.
* @return bool Success indicator.
*/
static public function delete(string $id): bool {
$db = Storage::init();
$stmt = $db->prepare("DELETE FROM cache WHERE id=?;");
if (!$stmt || !$stmt->bindValue(1, $id, SQLITE3_TEXT) || !$stmt->execute()) {
throw new Exception($db->lastErrorMsg());
return false;
}
return true;
}
/**
* Get the total count of rows in the database.
* @return int Total number of rows.
*/
static public function count(): int {
$db = Storage::init();
return $db->querySingle("SELECT COUNT(*) FROM cache;");
}
/**
* Deletes all entries older than the given timespan.
* @param string $age See https://www.sqlite.org/lang_datefunc.html for valid values.
* @return bool Success indicator.
*/
static public function prune(string $age): bool {
$db = Storage::init();
$stmt = $db->prepare("DELETE FROM cache WHERE mod <= DATETIME('now', 'localtime', ?);");
if (!$stmt || !$stmt->bindValue(1, $age, SQLITE3_TEXT) || !$stmt->execute()) {
throw new Exception($db->lastErrorMsg());
return false;
}
return true;
}
}

50
src/telegram.php Normal file
View File

@ -0,0 +1,50 @@
<?php declare(strict_types=1);
/**
* Telegram Reporting Library 1.0
* (c) 2025 draconigen@dogpixels.net
* AGPL 3.0, see https://www.gnu.org/licenses/agpl-3.0.de.html
* Provided "as is", without warranty of any kind.
*/
# configurable telegram config file path
define('TELEGRAM_CONFIG_PATH', 'telegram.config.php');
# create file if it doesn't exist
if (!file_exists(TELEGRAM_CONFIG_PATH)) {
file_put_contents(TELEGRAM_CONFIG_PATH, "<?php\ndefine('TELEGRAM_BOT_API_TOKEN', '');\ndefine('TELEGRAM_TARGET_USERID', '9724740'); # @draconigen\n");
}
# load config file
include_once(TELEGRAM_CONFIG_PATH);
# check config file contents and raise exception if empty
if (empty(TELEGRAM_BOT_API_TOKEN) || empty(TELEGRAM_TARGET_USERID)) {
throw new Exception("Telegram config missing in " . TELEGRAM_CONFIG_PATH);
}
class Telegram {
public static function report(string $message) {
# prepare telegram api payload
$payload = [
'chat_id' => TELEGRAM_TARGET_USERID,
'text' => sprintf("```\n%s\n```", htmlspecialchars_decode($message)),
'parse_mode' => 'MarkdownV2'
];
# init & configure curl request
$curl = curl_init("https://api.telegram.org/bot" . TELEGRAM_BOT_API_TOKEN . "/sendMessage");
curl_setopt_array($curl, [
CURLOPT_POST => true, # set POST request method
CURLOPT_POSTFIELDS => http_build_query($payload), # attach url-encoded post data
CURLOPT_RETURNTRANSFER => true # return response, rather than printing it to stdout
]
);
# exec api call and check response
$response = curl_exec($curl);
if (!json_decode($response)->ok) {
throw new Exception("Telegram API Error:\n{$response}");
}
}
}

0
uppies/.gitkeep Normal file
View File