Initial import.

This commit is contained in:
flash 2020-06-10 16:03:13 +00:00
commit 2f71fa5e62
88 changed files with 6460 additions and 0 deletions

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text=auto

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
/.debug
/public/ss
/uploads
/config.php

46
cron.php Normal file
View File

@ -0,0 +1,46 @@
<?php
namespace YTKNS;
if(!defined('YTKNS_SEM_NAME'))
define('YTKNS_SEM_NAME', 'b');
if(!defined('YTKNS_SFM_PATH'))
define('YTKNS_SFM_PATH', sys_get_temp_dir() . DIRECTORY_SEPARATOR . '{04884071-d334-4388-947a-4fd8c4d4f4ea}');
if(!is_file(YTKNS_SFM_PATH))
touch(YTKNS_SFM_PATH);
$ftok = ftok(YTKNS_SFM_PATH, YTKNS_SEM_NAME);
$semaphore = sem_get($ftok, 1);
if(!sem_acquire($semaphore))
die('Failed to acquire semaphore.' . PHP_EOL);
require_once __DIR__ . '/startup.php';
// Destroy old sessions
UserSession::purge();
// Resynchronise use counts
Upload::resync(EFFECT_UPLOADS);
// Destroy orphaned uploads
Upload::purgeOrphans();
// Get task queue
$taskQueue = ZoneTask::queue();
// Plow through tasks
// TODO: make task functions modular
while($task = array_shift($taskQueue)) {
if(!isset($zoneInfo) || $zoneInfo->getId() !== $task->getZoneId())
$zoneInfo = $task->getZone();
switch($task->getName()) {
case 'screenshot':
$zoneInfo->takeScreenshot();
break;
}
$task->delete();
}
sem_release($semaphore);

BIN
public/assets/accept.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 781 B

BIN
public/assets/add.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 B

BIN
public/assets/arrow_up.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 B

BIN
public/assets/bomb.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 793 B

BIN
public/assets/cancel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 B

BIN
public/assets/delete.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 715 B

BIN
public/assets/drive_add.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 623 B

554
public/assets/editor.css Normal file
View File

@ -0,0 +1,554 @@
.ye {
display: flex;
min-height: 500px;
border-bottom: 1px solid #ddd;
margin: -1px;
}
.ye-sidebar {
flex: 0 0 auto;
min-width: 250px;
display: flex;
flex-direction: column;
}
.ye-sidebar-buttons {
flex: 0 0 auto;
display: flex;
background-color: #eee;
border-bottom: 1px solid #ddd;
justify-content: flex-start;
align-items: center;
height: 28px;
}
.ye-sidebar-buttons-separator {
width: 1px;
height: 22px;
margin: 1px;
background-color: #ddd;
}
.ye-sidebar-buttons-button {
margin: 1px;
width: 24px;
height: 24px;
cursor: pointer;
background-size: 16px 16px;
background-repeat: no-repeat;
background-position: center center;
background-image: url('page_white.png');
}
.ye-sidebar-buttons-button:hover,
.ye-sidebar-buttons-button:focus {
border: 1px solid #7ca1cd;
background-color: #deecfd;
}
.ye-sidebar-buttons-button:active {
background-color: #f1f7fe;
}
.ye-sidebar-buttons-button--save { background-image: url('accept.png'); }
.ye-sidebar-buttons-button--cancel { background-image: url('cancel.png'); }
.ye-sidebar-buttons-button--add { background-image: url('add.png'); }
.ye-sidebar-buttons-button--preview { background-image: url('page_white_magnify.png'); }
.ye-sidebar-buttons-button--edit { background-image: url('page_white_edit.png'); }
.ye-sidebar-buttons-button--live { background-image: url('page_white_link.png'); }
.ye-sidebar-buttons-button--reset { background-image: url('arrow_refresh.png'); }
.ye-sidebar-effects {
flex: 1 1 auto;
}
.ye-sidebar-effects-empty {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.ye-sidebar-effects-effect {
display: flex;
justify-content: space-between;
border-bottom: 1px solid #ddd;
padding: 5px;
}
.ye-sidebar-effects-effect-name {
padding: 0 5px;
}
.ye-sidebar-effects-effect-actions {
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
align-items: center;
}
.ye-sidebar-effects-effect-actions-action {
margin: 1px;
width: 20px;
height: 20px;
cursor: pointer;
background-size: 16px 16px;
background-repeat: no-repeat;
background-position: center center;
background-image: url('page_white.png');
}
.ye-sidebar-effects-effect-actions-action--edit { background-image: url('pencil.png'); }
.ye-sidebar-effects-effect-actions-action--delete { background-image: url('delete.png'); }
.ye-main {
flex: 1 1 auto;
border-left: 1px solid #ddd;
display: flex;
flex-direction: column;
}
.ye-main-title {
flex: 0 0 auto;
border-left: 1px solid #ddd;
display: flex;
flex-direction: column;
justify-content: center;
background-color: #eee;
border-bottom: 1px solid #ddd;
height: 28px;
font-size: 1.2em;
padding: 0 6px;
}
.ye-main-container {
flex: 1 1 auto;
display: flex;
flex-direction: column;
align-items: stretch;
}
.ye-main-welcome {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.ye-main-effect-delete-confirm {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.ye-main-effect-delete-confirm-text {
font-size: 1.2em;
line-height: 1.5em;
}
.ye-main-effect-delete-confirm-actions {
display: flex;
margin: 5px;
}
.ye-main-effect-delete-confirm-actions-action {
cursor: pointer;
margin: 0 5px;
padding: 5px 10px;
font-size: 1.1em;
border-radius: 5px;
min-width: 100px;
text-align: center;
color: #fff;
transition: background-color .1s;
}
.ye-main-effect-delete-confirm-actions-action--accept { background-color: #28a745; }
.ye-main-effect-delete-confirm-actions-action--accept:hover,
.ye-main-effect-delete-confirm-actions-action--accept:focus { background-color: #218838; }
.ye-main-effect-delete-confirm-actions-action--accept:active { background-color: #1e7e34; }
.ye-main-effect-delete-confirm-actions-action--deny { background-color: #dc3545; }
.ye-main-effect-delete-confirm-actions-action--deny:hover,
.ye-main-effect-delete-confirm-actions-action--deny:focus { background-color: #c82333; }
.ye-main-effect-delete-confirm-actions-action--deny:active { background-color: #bc2130; }
.ye-main-details {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
max-width: 400px;
margin: 0 auto;
}
.ye-main-details-fields {
width: 100%;
}
.ye-main-details-fields-field {
display: flex;
margin: 5px 0;
}
.ye-main-details-fields-field-name {
min-width: 100px;
}
.ye-main-details-fields-field-wrap {
border: 1px solid #000;
min-width: 300px;
height: 22px;
display: flex;
padding: 0 2px;
background-color: #eee;
color: #444;
}
.ye-main-details-fields-field-input {
border-width: 0;
flex-grow: 1;
font: 12px/20px Tahoma, Geneva, 'Dejavu Sans', Arial, Helvetica, sans-serif;
background-color: #eee;
color: #000;
}
.ye-main-details-fields-field--readonly .ye-main-details-fields-field-wrap,
.ye-main-details-fields-field--readonly .ye-main-details-fields-field-input {
color: #666;
}
.ye-applet-effects-list {
display: flex;
flex-direction: column;
}
.ye-applet-effects-list-item {
display: flex;
justify-content: space-between;
padding: 5px;
border-bottom: 1px solid #ddd;
}
.ye-applet-effects-list-item--used {
opacity: .5;
}
.ye-applet-effects-list-item-name {
padding: 1px 5px;
}
.ye-applet-effects-list-item-actions {
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
align-items: center;
}
.ye-applet-effects-list-item-actions-action {
margin: 1px;
width: 20px;
height: 20px;
cursor: pointer;
background-size: 16px 16px;
background-repeat: no-repeat;
background-position: center center;
background-image: url('page_white.png');
}
.ye-applet-effects-list-item-actions-action--add { background-image: url('add.png'); }
.ye-applet-uploads {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
.ye-applet-uploads-progress {
width: 100%;
height: 100%;
flex: 1 1 auto;
display: flex;
justify-content: center;
align-items: center;
padding: 10px;
}
.ye-applet-uploads-progress-bar {
height: 24px;
width: 100%;
flex: 0 0 auto;
}
.ye-applet-uploads-cancel {
margin: 5px;
margin-top: 0;
font-size: 1.2em;
padding: 5px;
cursor: pointer;
}
.ye-applet-uploads-dropzone {
flex: 1 1 auto;
min-height: 200px;
padding: 5px;
cursor: pointer;
}
.ye-applet-uploads-dropzone-inner {
width: 100%;
height: 100%;
background-color: #ccc;
background-image: radial-gradient(circle at center, #eee, #bbb);
border: 5px dashed #888;
border-radius: 5px;
display: flex;
justify-content: center;
align-items: center;
font-size: 1.5em;
line-height: 1.5em;
transition: border-color .2s;
}
.ye-applet-uploads-dropzone--active .ye-applet-uploads-dropzone-inner {
border-color: #444;
}
.ye-applet-uploads-hidden {
display: none;
}
.ye-applet-editor {
}
.ye-applet-editor--fill {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.ye-applet-editor-empty {
font-size: 1.2em;
line-height: 1.5em;
}
.ye-applet-editor-properties {
display: flex;
flex-direction: column;
}
.ye-applet-editor-properties-property {
display: flex;
flex-direction: column;
border-bottom: 1px solid #ddd;
}
.ye-applet-editor-properties-property-title {
font-size: 1.1em;
line-height: 1.5em;
padding: 2px 5px;
}
.ye-applet-editor-properties-property-wrap {
display: flex;
flex: 1 1 auto;
justify-content: space-between;
}
.ye-applet-editor-properties-property-none {
font-size: .9em;
padding: 2px 10px;
}
.ye-applet-editor-properties-property-reset {
flex: 0 0 auto;
width: 20px;
height: 20px;
margin: 4px;
background-image: url('bomb.png');
background-size: 16px 16px;
background-position: center center;
background-repeat: no-repeat;
cursor: pointer;
}
.ye-applet-editor-properties-property-bool {
display: block;
width: 100%;
}
.ye-applet-editor-properties-property-bool-toggle {
margin: 7px;
}
.ye-applet-editor-properties-property-int {
display: block;
width: 100%;
}
.ye-applet-editor-properties-property-int-input {
margin: 2px 5px;
padding: 2px;
border: 1px solid #aaa;
}
.ye-applet-editor-properties-property-int-input:focus {
border-color: #f78f2e;
}
.ye-applet-editor-properties-property-upload {
display: flex;
align-items: center;
}
.ye-applet-editor-properties-property-upload-id {
font-size: .9em;
padding: 2px 10px;
}
.ye-applet-editor-properties-property-upload-select {
flex: 0 0 auto;
width: 20px;
height: 20px;
margin: 4px;
background-image: url('drive_add.png');
background-size: 16px 16px;
background-position: center center;
background-repeat: no-repeat;
cursor: pointer;
}
.ye-applet-editor-properties-property-select {
display: block;
width: 100%;
}
.ye-applet-editor-properties-property-select-input {
margin: 5px;
padding: 2px;
border: 1px solid #aaa;
}
.ye-applet-editor-properties-property-select-input:focus {
border-color: #f78f2e;
}
.ye-applet-editor-properties-property-colour {
display: block;
width: 100%;
}
.ye-applet-editor-properties-property-colour-input {
margin: 5px;
padding: 2px;
border: 1px solid #aaa;
}
.ye-applet-editor-properties-property-colour-input:focus {
border-color: #f78f2e;
}
.ye-applet-editor-properties-property-string {
display: flex;
width: 100%;
}
.ye-applet-editor-properties-property-string-input {
margin: 5px;
padding: 2px;
border: 1px solid #aaa;
flex: 1 1 auto;
}
.ye-applet-editor-properties-property-string-input:focus {
border-color: #f78f2e;
}
.ye-applet-editor-properties-property-gradient {
flex-grow: 1;
}
.ye-applet-editor-properties-property-gradient-preview {
margin: 0 5px;
height: 50px;
border: 1px solid #aaa;
}
.ye-applet-editor-properties-property-gradient-direction {
display: flex;
align-items: center;
}
.ye-applet-editor-properties-property-gradient-direction-circle {
margin: 6px;
width: 34px;
height: 34px;
border-radius: 100%;
border: 1px solid #8c8c8c;
padding: 1px;
background: #fefefe linear-gradient(0deg, #fdfdfd, #d9d9d9);
box-shadow: inset 0 0 0 1px #fff;
}
.ye-applet-editor-properties-property-gradient-direction-circle:active {
border-color: #f78f2e;
}
.ye-applet-editor-properties-property-gradient-direction-circle-value {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: flex-start;
pointer-events: none;
}
.ye-applet-editor-properties-property-gradient-direction-circle-indicator {
background: #999;
width: 2px;
height: 50%;
border-radius: 1px;
pointer-events: none;
}
.ye-applet-editor-properties-property-gradient-direction-input {
margin: 5px;
margin-left: 0;
padding: 2px;
border: 1px solid #aaa;
width: 60px;
}
.ye-applet-editor-properties-property-gradient-direction-input:focus {
border-color: #f78f2e;
}
.ye-applet-editor-properties-property-gradient-points-add {
margin: 1px;
margin-left: auto;
border: 1px solid #707070;
border-radius: 2px;
background: linear-gradient(180deg, #fcfcfc 0%, #ebebeb 50%, #dbdbdb 50%) #ebebeb;
cursor: pointer;
padding: 0 5px;
font: 12px/20px Tahoma, Geneva, 'Dejavu Sans', Arial, Helvetica, sans-serif;
display: flex;
align-items: center;
color: #000;
text-decoration: none;
transition: border-color .2s;
}
.ye-applet-editor-properties-property-gradient-points-add:hover,
.ye-applet-editor-properties-property-gradient-points-add:focus {
border-color: #f78f2e;
}
.ye-applet-editor-properties-property-gradient-points-add:active {
background: linear-gradient(0deg, #fcfcfc 0%, #ebebeb 50%, #dbdbdb 50%) #ebebeb;
}
.ye-applet-editor-properties-property-gradient-points-add-icon {
width: 16px;
height: 16px;
background-image: url('add.png');
background-size: 16px 16px;
background-position: center center;
background-repeat: no-repeat;
margin-right: 4px;
}
.ye-applet-editor-properties-property-gradient-points {
display: flex;
flex-direction: column;
max-height: 400px;
overflow: auto;
}
.ye-applet-editor-properties-property-gradient-points-point {
display: flex;
align-items: center;
}
.ye-applet-editor-properties-property-gradient-points-point-actions {
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
align-items: center;
}
.ye-applet-editor-properties-property-gradient-points-point-actions-action {
margin: 1px;
width: 20px;
height: 20px;
cursor: pointer;
background-size: 16px 16px;
background-repeat: no-repeat;
background-position: center center;
background-image: url('page_white.png');
}
.ye-applet-editor-properties-property-gradient-points-point-actions-action--delete { background-image: url('delete.png'); }
.ye-applet-editor-properties-property-gradient-points-point-colour {
margin: 2px 5px;
display: block;
overflow: hidden;
width: 41px;
height: 21px;
border: 1px solid #aaa;
box-shadow: inset 0 0 0 1px #fff;
}
.ye-applet-editor-properties-property-gradient-points-point-colour:active,
.ye-applet-editor-properties-property-gradient-points-point-colour:focus-within {
border-color: #f78f2e;
}
.ye-applet-editor-properties-property-gradient-points-point-colour-value {
position: relative;
top: -500px;
}
.ye-applet-editor-properties-property-gradient-points-point-offset-range {
flex: 1 1 auto;
}
.ye-applet-editor-properties-property-gradient-points-point-offset-numeric {
margin: 5px;
padding: 2px;
border: 1px solid #aaa;
width: 60px;
}
.ye-applet-editor-properties-property-gradient-points-point-offset-numeric:focus {
border-color: #f78f2e;
}

1081
public/assets/editor.js Normal file

File diff suppressed because it is too large Load Diff

BIN
public/assets/link.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 B

BIN
public/assets/pencil.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

68
public/assets/shared.css Normal file
View File

@ -0,0 +1,68 @@
* {
position: relative;
box-sizing: border-box;
outline-style: none !important;
}
@keyframes SharedAnimation_Spin360 {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
html,
body,
#container {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
.NewCreatePageEffect_Main {
font-family: sans-serif;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.ZoomText {
font-family: sans-serif;
position: absolute;
left: 0;
top: 0;
width: 810px;
z-index: 10;
text-align: center;
}
.ZoomText_Child {
position: absolute;
width: 810px;
}
.BackgroundImage {
position: absolute;
width: 100%;
height: 100%;
z-index: 0;
}
.BackgroundImage.Cover {
width: 10000px;
height: 10000px;
top: -5000px;
left: -5000px;
}
.ForegroundImage {
position: absolute;
overflow: hidden;
width: 100%;
height: 100%;
z-index: 100;
display: flex;
justify-content: center;
align-items: center;
}

64
public/assets/shared.js Normal file
View File

@ -0,0 +1,64 @@
var _paq = window._paq || [];
_paq.push(['disableCookies']);
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
_paq.push(['enableHeartBeatTimer']);
(function() {
_paq.push(['setTrackerUrl', '//uiharu.railgun.sh/mtm']);
_paq.push(['setSiteId', 'zlwmGOjMBk5J']);
var g = document.createElement('script');
g.type = 'text/javascript'; g.async = true;
g.defer = true; g.src = '//uiharu.railgun.sh/mtm.js';
document.head.appendChild(g);
})();
function synchroniseBackgroundWithAudio(bgi, target) {
var cnt = document.getElementById(target || 'BackgroundImage') || document.getElementById('container'),
bga = document.getElementById('BackgroundAudio'),
rdy = false,
bgs = [];
if(!cnt)
return;
var computed = getComputedStyle(cnt);
if(computed.backgroundImage && target !== 'ForegroundImage')
bgs = computed.backgroundImage.split(',');
if(!bga) {
bgs.splice(0, 0, 'url(' + bgi + ')');
cnt.style.backgroundImage = bgs.join(',');
return;
}
var onReady = function(url) {
bgs.splice(0, 0, 'url(' + url + ')');
cnt.style.backgroundImage = bgs.join(',');
bga.play();
};
bga.addEventListener('canplay', function() {
rdy = true;
});
bga.pause();
bga.currentTime = 0;
var xhr = new XMLHttpRequest;
xhr.responseType = 'blob';
xhr.onreadystatechange = function() {
if(xhr.readyState !== 4)
return;
bgi = URL.createObjectURL(xhr.response);
if(rdy)
onReady(bgi);
else
bga.addEventListener('canplay', function() {
onReady(bgi);
});
};
xhr.open('GET', bgi);
xhr.send();
}

BIN
public/assets/spinner.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

574
public/assets/style.css Normal file
View File

@ -0,0 +1,574 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
position: relative;
outline-style: none !important;
}
html, body {
width: 100%;
height: 100%;
}
body {
font: 12px/20px Tahoma, Geneva, 'Dejavu Sans', Arial, Helvetica, sans-serif;
background-color: #f8f8f8;
}
.hidden,
[hidden] {
display: none !important;
visibility: hidden !important;
}
.error {
background: #922;
color: #fff;
font-size: 1.2em;
line-height: 1.5em;
padding: 5px 10px;
border-radius: 5px;
background-image: linear-gradient(0deg, #922, #f22);
text-shadow: 0 1px 2px rgba(0, 0, 0, .5);
box-shadow: 0 1px 2px #000;
}
.noscript {
margin: 5px;
}
.wrapper {
max-width: 1000px;
margin: 0 auto;
background-color: #fff;
box-shadow: 0 1px 4px #000;
}
@media(min-width: 1000px) {
body { padding-top: 1px; }
.wrapper { margin: 3px auto 10px; }
}
.footer {
font-size: 10px;
padding: 0 5px;
text-align: right;
color: #888;
}
.footer a {
color: inherit;
text-decoration: none;
}
.footer a:hover,
.footer a:focus,
.footer a:active {
color: #222;
text-decoration: underline;
}
.header {}
.header-title {
padding-top: 10%;
background: url('ytkns.png') center / cover;
display: block;
}
.header-usermenu {
border: 1px solid #000;
background: linear-gradient(180deg, #545454 0%, #1a1a1a 50%, #000 50%) #545454;
}
.header-navigation {
border: 1px solid #000;
background: linear-gradient(180deg, #5a5a5a 0%, #272727 50%, #111 50%) #5a5a5a;
font-size: 1.4em;
line-height: 1.5em;
}
.header-navigation-item,
.header-usermenu-item {
color: #fff;
text-decoration: none;
padding: 2px 10px;
display: inline-block;
transition: background-color .1s;
}
.header-navigation-item:hover,
.header-navigation-item:active,
.header-usermenu-item:hover,
.header-usermenu-item:active {
background-color: rgba(255, 255, 255, .1);
}
.header-navigation-item:active,
.header-usermenu-item:active {
background-color: rgba(0, 0, 0, .5);
}
.content {
display: block;
padding: 1px;
}
.information {
margin: 5px;
display: block;
border: 1px solid #000;
padding: 2px 5px;
border-radius: 5px;
}
.information-title {
border: 1px solid #000;
border-radius: 10px;
padding: 0 8px;
}
.page-title {
padding: 8px 5px;
}
.page-subtitle {
padding: 2px 5px;
}
.page-paragraph {
margin: 1px 5px;
}
.zone-creation-form {
margin: 10px auto;
max-width: 400px;
width: 100%;
}
.zone-creation-input {
display: flex;
margin: 5px 0;
}
.zone-creation-label {
min-width: 100px;
}
.zone-creation-wrap {
border: 1px solid #000;
min-width: 300px;
height: 22px;
display: flex;
padding: 0 2px;
background-color: #eee;
color: #444;
}
.zone-creation-value {
border-width: 0;
flex-grow: 1;
font: 12px/20px Tahoma, Geneva, 'Dejavu Sans', Arial, Helvetica, sans-serif;
background-color: #eee;
color: #000;
}
.zone-creation-actions {
text-align: center;
}
.zone-creation-action {
border: 1px solid #707070;
border-radius: 2px;
background: linear-gradient(180deg, #fcfcfc 0%, #ebebeb 50%, #dbdbdb 50%) #ebebeb;
cursor: pointer;
padding: 2px 10px;
margin: 1px;
font: 20px/25px Tahoma, Geneva, 'Dejavu Sans', Arial, Helvetica, sans-serif;
display: inline-block;
color: #000;
text-decoration: none;
transition: border-color .2s;
}
.zone-creation-action:hover,
.zone-creation-action:focus {
border-color: #3c7fb1;
}
.zone-creation-action:active {
background: linear-gradient(0deg, #fcfcfc 0%, #ebebeb 50%, #dbdbdb 50%) #ebebeb;
}
.zone-creation-paragraph {
text-align: center;
padding: 5px 10px;
font-size: 1.2em;
line-height: 1.5em;
}
.loading-editor {
max-width: 400px;
margin: 10px auto;
display: flex;
justify-content: center;
align-items: center;
}
.loading-editor-image {
width: 32px;
height: 32px;
background-image: url('spinner.gif');
flex: 0 0 auto;
}
.loading-editor-text {
font-size: 1.5em;
line-height: 1.5em;
padding: 0 10px;
}
.auth {
display: flex;
flex-direction: column;
max-width: 300px;
width: 100%;
margin: 0 auto;
}
.auth-field {
margin: 5px 0;
}
.auth-field-label {
font-size: 1.2em;
line-height: 1.5em;
padding: 2px 5px;
}
.auth-field-value {
display: flex;
}
.auth-field-value-input {
width: 100%;
padding: 2px;
border: 1px solid #aaa;
}
.auth-field-value-input:focus {
border-color: #f78f2e;
}
.auth-option {
font-size: .9em;
line-height: 1.5em;
padding: 5px 0;
display: flex;
justify-content: center;
align-items: center;
}
.auth-option-input {
margin: 0 5px;
}
.auth-buttons {
display: flex;
justify-content: center;
align-items: center;
margin: 5px 0;
}
.auth-fid .auth-buttons {
flex-direction: column;
}
.auth-buttons-button {
padding: 2px 5px;
min-width: 75px;
cursor: pointer;
}
.auth-fid .auth-buttons-button {
width: 100%;
border: 1px solid #707070;
border-radius: 2px;
background-image: linear-gradient(180deg, #fcfcfc 0%, #ebebeb 50%, #dbdbdb 50%);
background-color: #ebebeb;
cursor: pointer;
padding: 2px 10px;
margin: 1px;
font: 12px/20px Tahoma, Geneva, 'Dejavu Sans', Arial, Helvetica, sans-serif;
display: inline-block;
color: #000;
text-decoration: none;
transition: border-color .2s;
}
.auth-fid .auth-buttons-button:hover,
.auth-fid .auth-buttons-button:focus {
border-color: #3c7fb1;
}
.auth-fid .auth-buttons-button:active {
background-image: linear-gradient(0deg, #fcfcfc 0%, #ebebeb 50%, #dbdbdb 50%);
}
.auth-fid .auth-buttons-button-fid {
font-size: 16px;
line-height: 30px;
background-image: linear-gradient(180deg, #9475b2 0%, #9475b2 50%, #8559a5 50%);
color: #fff;
}
.auth-fid .auth-buttons-button-fid:hover,
.auth-fid .auth-buttons-button-fid:focus {
border-color: #9475b2;
}
.auth-fid .auth-buttons-button-fid:active {
background-image: linear-gradient(0deg, #9475b2 0%, #9475b2 50%, #8559a5 50%);
}
.invites {}
.invites .error {
margin: 5px;
}
.invites-create {
display: flex;
justify-content: center;
margin: 5px;
}
.invites-create-button {
padding: 2px 10px;
min-width: 75px;
cursor: pointer;
font-size: 1.2em;
line-height: 1.5em;
}
.invites-list {
display: flex;
flex-direction: column;
max-width: 900px;
width: 100%;
margin: 10px auto;
}
.invites-list-item {
display: flex;
margin-top: 1px;
padding-bottom: 1px;
flex-wrap: wrap;
}
.invites-list-item:not(:last-child) {
border-bottom: 1px solid #000;
}
.invites-list-item:nth-child(even) {
background-color: #f0f0f0;
}
.invites-list-item-created,
.invites-list-item-used,
.invites-list-item-user {
max-width: 200px;
width: 100%;
}
.invites-list-item-token {
max-width: 300px;
width: 100%;
}
.invites-list-item-token code {
display: block;
color: #000;
background: #000;
font-size: 1.1em;
text-align: center;
border-radius: 10px;
padding: 0 10px;
cursor: pointer;
transition: background .5s;
}
.invites-list-item-token code:hover,
.invites-list-item-token code:focus {
color: #fff;
}
.invites-list-item-token code:active {
background: #fff;
transition: background .1s;
}
.nozone {
text-align: center;
}
.nozone h1 {
margin: 20px;
}
.nozone p {
font-size: 1.2em;
margin: 10px;
}
.nozone a {
color: #000;
transition: text-shadow .2s, color .2s;
}
.nozone a:hover,
.nozone a:focus {
text-decoration: none;
color: #22f;
text-shadow: 0 0 10px #22f;
}
.nozone a:active {
color: #f22;
text-shadow: 0 0 10px #f22;
}
.my-zones-list {
display: flex;
flex-direction: column;
}
.my-zones-list-item {
display: flex;
align-items: center;
padding: 5px;
margin: 0 5px;
background-color: #fff;
}
.my-zones-list-item:not(:first-child) {
border-top: 1px solid #000;
}
.my-zones-list-item-info {
display: flex;
flex-direction: column;
flex: 1 1 auto;
word-wrap: normal;
word-break: break-word;
}
.my-zones-list-item-info-name {
font-size: 1.4em;
line-height: 1.5em;
}
.my-zones-list-item-info-name a {
color: #000;
text-decoration: none;
}
.my-zones-list-item-info-name a:hover,
.my-zones-list-item-info-name a:focus,
.my-zones-list-item-info-name a:active {
text-decoration: underline;
}
.my-zones-list-item-info-title {
margin-right: 5px;
}
.my-zones-list-item-actions {
display: flex;
flex: 0 0 auto;
}
.my-zones-list-item-actions-action {
margin: 1px;
width: 24px;
height: 24px;
cursor: pointer;
border-radius: 100%;
background-size: 16px 16px;
background-repeat: no-repeat;
background-position: center center;
background-image: url('page_white.png');
background-color: #fff;
transition: background-color .2s;
}
.my-zones-list-item-actions-action:hover,
.my-zones-list-item-actions-action:focus {
background-color: #eee;
}
.my-zones-list-item-actions-action:active {
background-color: #ddd;
}
.my-zones-list-item-actions-action--view { background-image: url('link.png'); }
.my-zones-list-item-actions-action--edit { background-image: url('pencil.png'); }
.my-zones-list-item-actions-action--delete { background-image: url('bin_closed.png'); background-position: 2px center; }
.pagination {
display: flex;
justify-content: center;
flex-wrap: wrap;
}
.pagination-page {
flex: 0 0 auto;
min-width: 24px;
height: 24px;
display: flex;
justify-content: center;
align-items: center;
color: #000;
text-decoration: none;
}
.pagination-page:not(:first-child) {
border-left: 1px solid #000;
}
.pagination-page-current {
font-weight: 700;
}
.zones-sorts {
display: flex;
align-items: center;
margin: 2px 5px;
}
.zones-sorts-sort {
display: inline-flex;
align-items: center;
padding: 2px 5px;
color: #000;
text-decoration: none;
}
.zones-sorts-sort:hover,
.zones-sorts-sort:focus {
text-decoration: underline;
}
.zones-sorts-sort img {
margin-left: 4px;
}
.zones-list {
display: flex;
justify-content: center;
align-items: flex-start;
flex-wrap: wrap;
}
.zones-list-item {
display: flex;
align-items: center;
flex-direction: column;
width: 240px;
margin: 4px;
color: #000;
text-decoration: none;
box-shadow: 0 1px 3px #888;
background-color: #fff;
}
.zones-list-item a {
color: #000;
text-decoration: none;
}
.zones-list-item a:hover,
.zones-list-item a:focus {
text-decoration: underline;
}
.zones-list-item-screenshot {
border: 1px solid #000;
display: block;
margin-top: 18px;
}
.zones-list-item-screenshot-image {
width: 200px;
height: 150px;
display: inline-block;
vertical-align: middle;
}
.zones-list-item-info {
margin: 10px 5px;
text-align: center;
word-wrap: normal;
word-break: break-word;
}
.zones-list-item-info-name {
font-size: 1.4em;
line-height: 1.2em;
margin: 2px 0;
}
.zones-list-item-info-title {
font-size: 1.1em;
line-height: 1.2em;
margin: 2px 0;
}
.zones-list-item-info-actions {
display: flex;
justify-content: center;
align-items: center;
flex: 0 0 auto;
margin: 2px;
}
.zones-list-item-info-actions-action {
margin: 1px;
width: 24px;
height: 24px;
cursor: pointer;
border-radius: 100%;
background-size: 16px 16px;
background-repeat: no-repeat;
background-position: center center;
background-image: url('page_white.png');
background-color: #fff;
transition: background-color .2s;
}
.zones-list-item-info-actions-action:hover,
.zones-list-item-info-actions-action:focus {
background-color: #eee;
}
.zones-list-item-info-actions-action:active {
background-color: #ddd;
}
.zones-list-item-info-actions-action--view { background-image: url('link.png'); }
.zones-list-item-info-actions-action--edit { background-image: url('pencil.png'); }
.zones-list-item-info-actions-action--delete { background-image: url('bin_closed.png'); background-position: 2px center; }

BIN
public/assets/ytkns.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

1071
public/index.php Normal file

File diff suppressed because it is too large Load Diff

27
src/Colour.php Normal file
View File

@ -0,0 +1,27 @@
<?php
namespace YTKNS;
final class Colour {
private int $raw = 0;
public function __construct(int $raw = 0) {
$this->setRaw($raw);
}
public static function create($value): ?Colour {
$colour = is_int($value) ? $value : (is_string($value) && ctype_digit($value) ? (int)$value : -1);
return $colour < 0 || $colour > 0xFFFFFF ? null : new static($colour);
}
public function getRaw(): int {
return $this->raw;
}
public function setRaw(int $raw): self {
$this->raw = $raw & 0xFFFFFF;
return $this;
}
public function getHex(): string {
return str_pad(dechex($this->raw), 6, '0', STR_PAD_LEFT);
}
}

56
src/Config.php Normal file
View File

@ -0,0 +1,56 @@
<?php
namespace YTKNS;
final class Config {
public const TYPE_ANY = '';
public const TYPE_STR = 'string';
public const TYPE_INT = 'integer';
public const TYPE_BOOL = 'boolean';
public const TYPE_ARR = 'array';
public const DEFAULTS = [
self::TYPE_ANY => null,
self::TYPE_STR => '',
self::TYPE_INT => 0,
self::TYPE_BOOL => false,
self::TYPE_ARR => [],
];
private static array $config = [];
public static function init(): void {
$raw = DB::prepare('SELECT `config_key`, `config_value` FROM `ytkns_config`');
$raw->execute();
$raw = $raw->fetchAll();
foreach($raw as $entry)
self::$config[$entry['config_key']] = unserialize($entry['config_value']);
}
public static function get(string $key, string $type = self::TYPE_ANY, $default = null) {
$value = self::$config[$key] ?? null;
if($type !== self::TYPE_ANY && gettype($value) !== $type)
$value = null;
return $value ?? $default ?? self::DEFAULTS[$type];
}
public static function set(string $key, $value, bool $soft = false): void {
self::$config[$key] = $value;
if(!$soft) {
$value = serialize($value);
$save = DB::prepare('REPLACE INTO `ytkns_config` (`config_key`, `config_value`) VALUES (:key, :value)');
$save->bindValue('key', $key);
$save->bindValue('value', $value);
$save->execute();
}
}
public static function setDefault(string $key, $value, bool $soft = false): void {
if($value !== null && self::get($key) === null)
self::set($key, $value, $soft);
}
}

32
src/DB.php Normal file
View File

@ -0,0 +1,32 @@
<?php
namespace YTKNS;
use PDO;
use PDOStatement;
use PDOException;
final class DB {
public const FLAGS = [
PDO::ATTR_CASE => PDO::CASE_NATURAL,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_ORACLE_NULLS => PDO::NULL_NATURAL,
PDO::ATTR_STRINGIFY_FETCHES => false,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::MYSQL_ATTR_INIT_COMMAND => "
SET SESSION
sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION',
time_zone = '+00:00';
",
];
public static $instance = null;
public static function init(...$args) {
self::$instance = new PDO(...$args);
}
public static function __callStatic(string $name, array $args) {
return self::$instance->{$name}(...$args);
}
}

View File

@ -0,0 +1,137 @@
<?php
namespace YTKNS\Effects;
use Exception;
use YTKNS\HtmlTag;
use YTKNS\PageBuilder;
use YTKNS\PageEffectInterface;
use YTKNS\PageEffectException;
use YTKNS\Upload;
use YTKNS\UploadNotFoundException;
class BackgroundAudioEffect implements PageEffectInterface {
private $oggUpload = null;
private $mp3Upload = null;
private bool $autoPlay = true;
private bool $loop = true;
private const OGG_MIME = [
'audio/ogg',
'application/ogg',
];
private const MP3_MIME = [
'audio/mpeg',
'application/x-font-gdos',
];
public function getEffectName(): string {
return 'Background Audio';
}
public function getEffectProperties(): array {
return [
[
'name' => 'ogg',
'title' => 'Ogg Audio Source',
'type' => [
'name' => 'upload',
'allowed' => self::OGG_MIME,
],
],
[
'name' => 'mp3',
'title' => 'MP3 Audio Source',
'type' => [
'name' => 'upload',
'allowed' => self::MP3_MIME,
],
],
[
'name' => 'autoplay',
'title' => 'Auto Play',
'default' => true,
'type' => [
'name' => 'bool',
],
],
[
'name' => 'loop',
'title' => 'Loop',
'default' => true,
'type' => [
'name' => 'bool',
],
],
];
}
public function setEffectParams(array $vars, bool $quiet = false): void {
try {
if(isset($vars['ogg']) && is_string($vars['ogg'])) {
try {
$this->oggUpload = Upload::byId($vars['ogg']);
} catch(Exception $ex) {
if(!$quiet)
throw $ex;
}
if(!$quiet && !in_array($this->oggUpload->getType(), self::OGG_MIME))
throw new PageEffectException('Ogg source upload was of invalid type.');
}
} catch(UploadNotFoundException $ex) {
$noOGG = true;
}
try {
if(isset($vars['mp3']) && is_string($vars['mp3'])) {
try {
$this->mp3Upload = Upload::byId($vars['mp3']);
} catch(Exception $ex) {
if(!$quiet)
throw $ex;
}
if(!$quiet && !in_array($this->mp3Upload->getType(), self::MP3_MIME))
throw new PageEffectException('MP3 source upload was of invalid type.');
}
} catch(UploadNotFoundException $ex) {
$noMP3 = true;
}
if(!empty($noMP3) && !empty($noOGG))
throw new PageEffectException('No audio source uploaded.');
if(isset($vars['autoplay']))
$this->autoPlay = is_bool($vars['autoplay']) ? $vars['autoplay'] : (is_string($vars['autoplay']) ? boolval($vars['autoplay']) : false);
if(isset($vars['loop']))
$this->loop = is_bool($vars['loop']) ? $vars['loop'] : (is_string($vars['loop']) ? boolval($vars['loop']) : false);
}
public function getEffectParams(): array {
return [
'ogg' => empty($this->oggUpload) ? null : $this->oggUpload->getId(),
'mp3' => empty($this->mp3Upload) ? null : $this->mp3Upload->getId(),
'autoplay' => $this->autoPlay,
'loop' => $this->loop,
];
}
public function applyEffect(PageBuilder $builder): void {
$audioTag = new HtmlTag('audio');
$audioTag->setAttribute('id', 'BackgroundAudio');
if($this->autoPlay)
$audioTag->setAttribute('autoplay', 'autoplay');
if($this->loop)
$audioTag->setAttribute('loop', 'loop');
if(!empty($this->oggUpload))
$audioTag->appendChild(new HtmlTag('source', ['type' => 'audio/ogg', 'src' => $this->oggUpload->getUrl()], true));
if(!empty($this->mp3Upload))
$audioTag->appendChild(new HtmlTag('source', ['type' => 'audio/mpeg', 'src' => $this->mp3Upload->getUrl()], true));
$builder->getBody()->appendChild($audioTag);
}
}

View File

@ -0,0 +1,401 @@
<?php
namespace YTKNS\Effects;
use Exception;
use YTKNS\Colour;
use YTKNS\Gradient;
use YTKNS\HtmlTag;
use YTKNS\PageBuilder;
use YTKNS\PageEffectInterface;
use YTKNS\PageEffectException;
use YTKNS\Upload;
use YTKNS\UploadNotFoundException;
class BackgroundImageEffect implements PageEffectInterface {
private const IMG_MIME = [
'image/png',
'image/jpeg',
'image/gif',
];
private const ATTACH_OPTS = [
'scroll' => 'Relative to browser',
'fixed' => 'Fixed to browser',
'local' => 'Local to browser',
];
private const POSITION_OPTS = [
'top left' => 'Top left',
'top center' => 'Top center',
'top right' => 'Top right',
'center left' => 'Center left',
'center center' => 'Center',
'center right' => 'Center right',
'bottom left' => 'Bottom left',
'bottom center' => 'Bottom center',
'bottom right' => 'Bottom right',
];
private const REPEAT_OPTS = [
'repeat' => 'Repeat',
'repeat-x' => 'Repeat horizontally',
'repeat-y' => 'Repeat vertically',
'space' => 'Repeat without clipping',
'round' => 'Repeat with stretching',
'no-repeat' => 'Don\'t repeat',
];
private const SIZE_OPTS = [
'auto auto' => 'Determine automatically',
'cover' => 'Cover entire screen',
'contain' => 'Contain within screen',
];
private const SLIDE_DIRS = [
'tl' => 'Top left',
'tc' => 'Top',
'tr' => 'Top right',
'cl' => 'Left',
'cr' => 'Right',
'bl' => 'Bottom left',
'bc' => 'Bottom',
'br' => 'Bottom right',
];
private const SLIDE_MIN = 0.01;
private const SLIDE_MAX = 1000;
private const SPIN_DIRS = [
'cw' => 'Clockwise',
'ccw' => 'Counterclockwise',
];
private const SPIN_MIN = 0.01;
private const SPIN_MAX = 1000;
private $imageUpload = null;
private $slide = false;
private $slideSpeed = 1;
private $slideDirection = 'br';
private $spin = false;
private $spinSpeed = 1;
private $spinDirection = 'cw';
private $attachment = null;
private $colour = null;
private $position = null;
private $repeat = null;
private $size = null;
private $syncWithAudio = false;
private $gradient = null;
private $gradientAnimate = false;
public function getEffectName(): string {
return 'Background Image';
}
public function getEffectProperties(): array {
return [
[
'name' => 'img',
'title' => 'Image',
'type' => [
'name' => 'upload',
'allowed' => self::IMG_MIME,
],
],
[
'name' => 'sld',
'title' => 'Slide Animation',
'default' => false,
'type' => [
'name' => 'bool',
],
],
[
'name' => 'sldspd',
'title' => 'Slide Animation Period',
'default' => 1,
'type' => [
'name' => 'float',
'min' => self::SLIDE_MIN,
'max' => self::SLIDE_MAX,
],
],
[
'name' => 'slddir',
'title' => 'Slide Animation Direction',
'default' => 'br',
'type' => [
'name' => 'select',
'options' => self::SLIDE_DIRS,
],
],
[
'name' => 'spin',
'title' => 'Spin Animation',
'default' => false,
'type' => [
'name' => 'bool',
],
],
[
'name' => 'spnspd',
'title' => 'Spin Animation Period',
'default' => 1,
'type' => [
'name' => 'float',
'min' => self::SPIN_MIN,
'max' => self::SPIN_MAX,
],
],
[
'name' => 'spndir',
'title' => 'Spin Animation Direction',
'default' => 'br',
'type' => [
'name' => 'select',
'options' => self::SPIN_DIRS,
],
],
[
'name' => 'attach',
'title' => 'Attachment',
'type' => [
'name' => 'select',
'options' => self::ATTACH_OPTS,
],
],
[
'name' => 'col',
'title' => 'Colour',
'type' => [
'name' => 'colour',
],
],
[
'name' => 'pos',
'title' => 'Position',
'type' => [
'name' => 'select',
'options' => self::POSITION_OPTS,
],
],
[
'name' => 'rep',
'title' => 'Repeat',
'type' => [
'name' => 'select',
'options' => self::REPEAT_OPTS,
],
],
[
'name' => 'size',
'title' => 'Size',
'type' => [
'name' => 'select',
'options' => self::SIZE_OPTS,
],
],
[
'name' => 'sync',
'title' => 'Synchronise with Background Audio',
'default' => false,
'type' => [
'name' => 'bool',
],
],
[
'name' => 'grad',
'title' => 'Gradient',
'default' => [],
'type' => [
'name' => 'gradient',
],
],
/*[
'name' => 'grdanm',
'title' => 'Apply animations to gradient',
'default' => false,
'type' => [
'name' => 'bool',
],
],*/
];
}
public function setEffectParams(array $vars, bool $quiet = false): void {
try {
if(isset($vars['img']) && is_string($vars['img'])) {
try {
$this->imageUpload = Upload::byId($vars['img']);
} catch(Exception $ex) {
if(!$quiet)
throw $ex;
}
if(!$quiet && !in_array($this->imageUpload->getType(), self::IMG_MIME))
throw new PageEffectException('Image upload was of invalid type.');
}
} catch(UploadNotFoundException $ex) {
$this->imageUpload = null;
}
if(isset($vars['sld']))
$this->slide = is_bool($vars['sld']) ? $vars['sld'] : (is_string($vars['sld']) ? boolval($vars['sld']) : false);
if(isset($vars['spin']))
$this->spin = is_bool($vars['spin']) ? $vars['spin'] : (is_string($vars['spin']) ? boolval($vars['spin']) : false);
if(isset($vars['sync']))
$this->syncWithAudio = is_bool($vars['sync']) ? $vars['sync'] : (is_string($vars['sync']) ? boolval($vars['sync']) : false);
if(isset($vars['grdanm']))
$this->gradientAnimate = is_bool($vars['grdanm']) ? $vars['grdanm'] : (is_string($vars['grdanm']) ? boolval($vars['grdanm']) : false);
if(isset($vars['sldspd'])) {
$this->slideSpeed = is_int($vars['sldspd']) || is_float($vars['sldspd']) ? $vars['sldspd'] : (is_string($vars['sldspd']) ? floatval($vars['sldspd']) : 1);
if(!$quiet && ($this->slideSpeed < self::SLIDE_MIN || $this->slideSpeed > self::SLIDE_MAX))
throw new PageEffectException(sprintf('Slide speed may not be less than %d or more than %d', self::SLIDE_MIN, self::SLIDE_MAX));
}
if(isset($vars['spnspd'])) {
$this->spinSpeed = is_int($vars['spnspd']) || is_float($vars['spnspd']) ? $vars['spnspd'] : (is_string($vars['spnspd']) ? floatval($vars['spnspd']) : 1);
if(!$quiet && ($this->spinSpeed < self::SPIN_MIN || $this->spinSpeed > self::SPIN_MAX))
throw new PageEffectException(sprintf('Spin speed may not be less than %d or more than %d', self::SPIN_MIN, self::SPIN_MAX));
}
if(isset($vars['slddir']) && is_string($vars['slddir']) && array_key_exists($vars['slddir'], self::SLIDE_DIRS))
$this->slideDirection = $vars['slddir'];
if(isset($vars['spndir']) && is_string($vars['spndir']) && array_key_exists($vars['spndir'], self::SPIN_DIRS))
$this->spinDirection = $vars['spndir'];
if(isset($vars['col']))
$this->colour = Colour::create($vars['col']);
try {
if(isset($vars['grad']))
$this->gradient = Gradient::fromArray($vars['grad']);
} catch(Exception $ex) {
if(!$quiet)
throw $ex;
}
if(isset($vars['attach']) && is_string($vars['attach']) && array_key_exists($vars['attach'], self::ATTACH_OPTS))
$this->attachment = $vars['attach'];
if(isset($vars['pos']) && is_string($vars['pos']) && array_key_exists($vars['pos'], self::POSITION_OPTS))
$this->position = $vars['pos'];
if(isset($vars['rep']) && is_string($vars['rep']) && array_key_exists($vars['rep'], self::REPEAT_OPTS))
$this->repeat = $vars['rep'];
if(isset($vars['size']) && is_string($vars['size']) && array_key_exists($vars['size'], self::SIZE_OPTS))
$this->size = $vars['size'];
}
public function getEffectParams(): array {
return [
'img' => empty($this->imageUpload) ? null : $this->imageUpload->getId(),
'sld' => $this->slide,
'sldspd' => $this->slideSpeed,
'slddir' => $this->slideDirection,
'spin' => $this->spin,
'spnspd' => $this->spinSpeed,
'spndir' => $this->spinDirection,
'attach' => $this->attachment,
'col' => empty($this->colour) ? 0 : $this->colour->getRaw(),
'pos' => $this->position,
'rep' => $this->repeat,
'size' => $this->size,
'sync' => $this->syncWithAudio,
'grad' => empty($this->gradient) ? null : $this->gradient->getArray(),
'grdanm' => $this->gradientAnimate,
];
}
public function applyEffect(PageBuilder $builder): void {
$head = $builder->getHead();
$body = $builder->getContainer();
$bgTarget = $body;
$styleText = '';
if(empty($_GET['preview']) && !$this->gradientAnimate) {
$bgTargetClasses = ['BackgroundImage'];
if($this->spin && $this->slide)
$bgTargetClasses[] = 'Cover';
$bgTarget = $body->appendChild(new HtmlTag('div', ['class' => implode(' ', $bgTargetClasses), 'id' => 'BackgroundImage']));
}
if(empty($_GET['preview']) && !empty($this->imageUpload)) {
if($this->slide) {
$imageSize = getimagesize($this->imageUpload->getPath());
$imageWidth = $imageSize[0];
$imageHeight = $imageSize[1];
$animSX = $this->slideDirection[1] === 'l' ? $imageWidth : 0;
$animSY = $this->slideDirection[0] === 't' ? $imageHeight : 0;
$animEX = $this->slideDirection[1] === 'r' ? $imageWidth : 0;
$animEY = $this->slideDirection[0] === 'b' ? $imageHeight : 0;
if($imageSize !== false) {
$slideAnim = '_' . bin2hex(random_bytes(8));
$styleText .= sprintf(
'@keyframes %s { 0%% { background-position: %dpx %dpx; } 100%% { background-position: %dpx %dpx; } } ',
$slideAnim, $animSX, $animSY, $animEX, $animEY
);
}
}
}
$styleBgColour = isset($this->colour)
? sprintf(' background-color: #%s;', $this->colour->getHex())
: ' background-color: #000;';
$animation = [];
$backgroundImage = [];
if($this->spin)
$animation[] = sprintf('SharedAnimation_Spin360 infinite linear %s %Fs', $this->spinDirection === 'cw' ? 'normal' : 'reverse', $this->spinSpeed);
if(isset($slideAnim))
$animation[] = sprintf('%s infinite linear %Fs', $slideAnim, $this->slideSpeed);
$syncWithAudio = empty($_GET['preview']) && $this->syncWithAudio;
if(!empty($this->imageUpload) && !$syncWithAudio)
$backgroundImage[] = sprintf('url(\'%s\')', $this->imageUpload->getUrl());
if($bgTarget !== $body) {
$styleText .= '#container {';
if(!empty($this->gradient))
$styleText .= 'background-image: ' . $this->gradient->getCSS() . ';';
$styleText .= $styleBgColour;
$styleText .= '}';
}
$styleText .= '#' . ($bgTarget->getAttribute('id')) . ' {';
if($bgTarget === $body) {
if(!empty($this->gradient))
$backgroundImage[] = $this->gradient->getCSS();
$styleText .= $styleBgColour;
}
if(!empty($backgroundImage))
$styleText .= sprintf(' background-image: %s;', implode(', ', $backgroundImage));
if(!empty($this->attachment))
$styleText .= sprintf(' background-attachment: %s;', $this->attachment);
if(!empty($this->position))
$styleText .= sprintf(' background-position: %s;', $this->position);
if(!empty($this->repeat))
$styleText .= sprintf(' background-repeat: %s;', $this->repeat);
if(!empty($this->size))
$styleText .= sprintf(' background-size: %s;', $this->size);
if(!empty($animation))
$styleText .= sprintf(' animation: %s;', implode(', ', $animation));
$styleText .= ' }';
$styleTag = new HtmlTag('style', ['type' => 'text/css']);
$styleTag->setTextContent($styleText);
$head->appendChild($styleTag);
if(!empty($this->imageUpload) && $syncWithAudio) {
$scriptText = 'window.addEventListener(\'DOMContentLoaded\', function() {';
$scriptText .= 'synchroniseBackgroundWithAudio(\'' . $this->imageUpload->getUrl() . '\');';
$scriptText .= '});';
$scriptTag = new HtmlTag('script', ['type' => 'text/javascript']);
$scriptTag->setTextContent($scriptText);
$head->appendChild($scriptTag);
}
}
}

View File

@ -0,0 +1,153 @@
<?php
namespace YTKNS\Effects;
use Exception;
use YTKNS\HtmlTag;
use YTKNS\HtmlText;
use YTKNS\PageBuilder;
use YTKNS\PageEffectInterface;
use YTKNS\Upload;
use YTKNS\UploadNotFoundException;
class ForegroundImageEffect implements PageEffectInterface {
private const IMG_MIME = [
'image/png',
'image/jpeg',
'image/gif',
];
private const SPIN_DIRS = [
'cw' => 'Clockwise',
'ccw' => 'Counterclockwise',
];
private const SPIN_MIN = 0.01;
private const SPIN_MAX = 1000;
private $imageUpload = null;
private $spin = false;
private $spinSpeed = 1;
private $spinDirection = 'cw';
private $syncWithAudio = false;
public function getEffectName(): string {
return 'Foreground Image';
}
public function getEffectProperties(): array {
return [
[
'name' => 'img',
'title' => 'Image',
'type' => [
'name' => 'upload',
'allowed' => self::IMG_MIME,
],
],
[
'name' => 'spin',
'title' => 'Spin Animation',
'default' => false,
'type' => [
'name' => 'bool',
],
],
[
'name' => 'spnspd',
'title' => 'Spin Animation Period',
'default' => 1,
'type' => [
'name' => 'float',
'min' => self::SPIN_MIN,
'max' => self::SPIN_MAX,
],
],
[
'name' => 'spndir',
'title' => 'Spin Animation Direction',
'default' => 'br',
'type' => [
'name' => 'select',
'options' => self::SPIN_DIRS,
],
],
[
'name' => 'sync',
'title' => 'Synchronise with Background Audio',
'default' => false,
'type' => [
'name' => 'bool',
],
],
];
}
public function setEffectParams(array $vars, bool $quiet = false): void {
try {
if(isset($vars['img']) && is_string($vars['img'])) {
try {
$this->imageUpload = Upload::byId($vars['img']);
} catch(Exception $ex) {
if(!$quiet)
throw $ex;
}
if(!$quiet && !in_array($this->imageUpload->getType(), self::IMG_MIME))
throw new PageEffectException('Image upload was of invalid type.');
}
} catch(UploadNotFoundException $ex) {
$this->imageUpload = null;
}
if(isset($vars['spin']))
$this->spin = is_bool($vars['spin']) ? $vars['spin'] : (is_string($vars['spin']) ? boolval($vars['spin']) : false);
if(isset($vars['sync']))
$this->syncWithAudio = is_bool($vars['sync']) ? $vars['sync'] : (is_string($vars['sync']) ? boolval($vars['sync']) : false);
if(isset($vars['spnspd'])) {
$this->spinSpeed = is_int($vars['spnspd']) || is_float($vars['spnspd']) ? $vars['spnspd'] : (is_string($vars['spnspd']) ? floatval($vars['spnspd']) : 1);
if(!$quiet && ($this->spinSpeed < self::SPIN_MIN || $this->spinSpeed > self::SPIN_MAX))
throw new PageEffectException(sprintf('Spin speed may not be less than %d or more than %d', self::SPIN_MIN, self::SPIN_MAX));
}
if(isset($vars['spndir']) && is_string($vars['spndir']) && array_key_exists($vars['spndir'], self::SPIN_DIRS))
$this->spinDirection = $vars['spndir'];
}
public function getEffectParams(): array {
return [
'img' => empty($this->imageUpload) ? null : $this->imageUpload->getId(),
'spin' => $this->spin,
'spnspd' => $this->spinSpeed,
'spndir' => $this->spinDirection,
'sync' => $this->syncWithAudio,
];
}
public function applyEffect(PageBuilder $builder): void {
$element = $builder->getContainer()->appendChild(new HtmlTag('div', ['class' => 'ForegroundImage']));
$imageTarget = $element->appendChild(new HtmlTag('div', ['class' => 'ForegroundImage_Image', 'id' => 'ForegroundImage']));
if(!empty($this->imageUpload)) {
$imageSize = getimagesize($this->imageUpload->getPath());
if($imageSize !== false) {
$styleText = sprintf('width: %dpx; height: %dpx', $imageSize[0], $imageSize[1]);
if(!empty($_GET['preview']) || !$this->syncWithAudio)
$styleText .= sprintf(';background-image:url(\'%s\')', $this->imageUpload->getUrl());
if(empty($_GET['preview']) && $this->spin)
$styleText .= sprintf(';animation: SharedAnimation_Spin360 infinite linear %s %Fs', $this->spinDirection === 'cw' ? 'normal' : 'reverse', $this->spinSpeed);
$imageTarget->setAttribute('style', $styleText);
$scriptText = 'window.addEventListener(\'DOMContentLoaded\', function() {';
$scriptText .= 'synchroniseBackgroundWithAudio(\'' . $this->imageUpload->getUrl() . '\', \'ForegroundImage\');';
$scriptText .= '});';
$scriptTag = new HtmlTag('script', ['type' => 'text/javascript']);
$scriptTag->setTextContent($scriptText);
$builder->getHead()->appendChild($scriptTag);
}
}
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace YTKNS\Effects;
use YTKNS\HtmlTag;
use YTKNS\HtmlText;
use YTKNS\PageBuilder;
use YTKNS\PageEffectInterface;
class NewlyCreatedPageEffect implements PageEffectInterface {
private $headerText = null;
private $subText = null;
public function getEffectName(): string {
return 'Newly Created Page Notice';
}
public function getEffectProperties(): array {
return [];
}
public function setEffectParams(array $vars, bool $quiet = false): void {
if(!empty($vars['h']))
$this->headerText = $vars['h'];
if(!empty($vars['s']))
$this->subText = $vars['s'];
}
public function getEffectParams(): array {
$vars = [];
if(!empty($this->headerText))
$vars['h'] = $this->headerText;
if(!empty($this->subText))
$vars['s'] = $this->subText;
return $vars;
}
public function applyEffect(PageBuilder $builder): void {
$builder->getContainer()->appendChild(new HtmlTag('div', ['class' => 'NewCreatePageEffect_Main'], [
new HtmlTag('h1', [], [new HtmlText($this->headerText ?? 'This page is still empty')]),
new HtmlTag('p', [], [new HtmlText($this->subText ?? 'Please come back later')]),
]));
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace YTKNS\Effects;
use YTKNS\HtmlTag;
use YTKNS\HtmlText;
use YTKNS\PageBuilder;
use YTKNS\PageEffectInterface;
use YTKNS\PageEffectException;
class ZoomTextEffect implements PageEffectInterface {
private const TEXT_MIN = 1;
private const TEXT_MAX = 1000;
private $text = '';
public function getEffectName(): string {
return 'Zoom Text';
}
public function getEffectProperties(): array {
return [
[
'name' => 'txt',
'title' => 'Text',
'type' => [
'name' => 'string',
'min' => self::TEXT_MIN,
'max' => self::TEXT_MAX,
],
],
];
}
public function setEffectParams(array $vars, bool $quiet = false): void {
if(isset($vars['txt']) && is_string($vars['txt'])) {
if(!$quiet && (mb_strlen($vars['txt']) < self::TEXT_MIN || mb_strlen($vars['txt']) > self::TEXT_MAX))
throw new PageEffectException('Your text is too long or too short.');
$this->text = $vars['txt'];
}
}
public function getEffectParams(): array {
return [
'txt' => $this->text,
];
}
public function applyEffect(PageBuilder $builder): void {
$tags = [];
for($i = 1; $i <= 50; $i++) {
$tags[] = new HtmlTag('div', ['class' => 'ZoomText_Child', 'style' => sprintf('font-size: %1$dpt; top: %1$dpx; left: %2$dpx; color: rgb(%3$d, %3$d, %3$d);', $i * 2, $i, $i === 50 ? 0 : (4 * $i))], [new HtmlText($this->text)]);
}
$builder->getContainer()->appendChild(new HtmlTag('div', ['class' => 'ZoomText'], $tags));
}
}

87
src/Gradient.php Normal file
View File

@ -0,0 +1,87 @@
<?php
namespace YTKNS;
use InvalidArgumentException;
final class Gradient {
private int $direction = 0;
private array $points = [];
public function getArray(): array {
$arr = [
'd' => $this->getDirection(),
'p' => [],
];
foreach($this->getPoints() as $point)
$arr['p'][] = $point->getArray();
return $arr;
}
public static function fromArray($array): ?self {
$gradient = new static;
if(!empty($array)) {
if(is_string($array))
$array = json_decode($array);
if(is_object($array))
$array = (array)$array;
if(is_array($array)) {
if(isset($array['d']) && is_int($array['d']))
$gradient->setDirection($array['d']);
if(isset($array['p']) && is_array($array['p'])) {
$gradient->resetPoints();
foreach($array['p'] as $point)
$gradient->addPoint(GradientPoint::fromArray($point));
}
}
}
if(count($gradient->points) < 2)
return null;
return $gradient;
}
public function getCSS(): string {
$points = [];
foreach($this->points as $point)
$points[] = $point->getCSS();
return sprintf('linear-gradient(%ddeg, %s)', $this->getDirection(), implode(', ', $points));
}
public function getDirection(): int {
return $this->direction;
}
public function setDirection(int $dir): self {
if($dir < 0 || $dir > 359)
throw new InvalidArgumentException('dir must be between 0 and 359.');
$this->direction = $dir;
return $this;
}
public function getPoints(): array {
return $this->points;
}
public function addPoint(GradientPoint $point): self {
if(!in_array($point, $this->points))
$this->points[] = $point;
return $this;
}
public function removePoint(GradientPoint $point): self {
$index = array_search($point, $this->points);
if($index !== null)
unset($this->points[$index]);
return $this;
}
public function resetPoints(): self {
$this->points = [];
return $this;
}
}

65
src/GradientPoint.php Normal file
View File

@ -0,0 +1,65 @@
<?php
namespace YTKNS;
use InvalidArgumentException;
final class GradientPoint {
private int $offset = 0;
private $colour = null;
public function __construct(int $offset = 0, \Colour $colour = null) {
$this->setOffset($offset);
if($colour !== null)
$this->setColour($colour);
}
public function getArray(): array {
return [
'o' => $this->getOffset(),
'c' => $this->getColour()->getRaw(),
];
}
public static function fromArray($array): self {
$point = new static;
if(!empty($array)) {
if(is_string($array))
$array = json_decode($array);
if(is_object($array))
$array = (array)$array;
if(is_array($array)) {
if(isset($array['o']) && is_int($array['o']))
$point->setOffset($array['o']);
if(isset($array['c']))
$point->setColour(Colour::create($array['c']));
}
}
return $point;
}
public function getOffset(): int {
return $this->offset;
}
public function setOffset(int $offset): self {
if($offset < 0 || $offset > 100)
throw new InvalidArgumentException('offset must be between 0 and 100');
$this->offset = $offset;
return $this;
}
public function getColour(): Colour {
if($this->colour === null)
$this->colour = new Colour;
return $this->colour;
}
public function setColour(Colour $colour): self {
$this->colour = $colour;
return $this;
}
public function getCSS(): string {
return sprintf('#%s %d%%', $this->getColour()->getHex(), $this->getOffset());
}
}

126
src/HtmlTag.php Normal file
View File

@ -0,0 +1,126 @@
<?php
namespace YTKNS;
class HtmlTag implements HtmlTypeInterface {
private string $tagName = 'div';
private array $attributes = [];
private array $children = [];
private bool $selfClosing = false;
public function __construct(string $tagName, array $attributes = [], $children = null) {
$this->tagName = $tagName;
foreach($attributes as $key => $val)
$this->setAttribute($key, $val);
if(is_bool($children))
$this->selfClosing = $children;
elseif(is_array($children)) {
foreach($children as $child)
if($child !== null && $child instanceof HtmlTypeInterface)
$this->appendChild($child);
}
}
public function getTagName(): string {
return $this->tagName;
}
public function getAttribute(string $name): ?string {
return $this->attributes[$name] ?? null;
}
public function setAttribute(string $name, $value): void {
if($value === null) {
$this->removeAttribute($name);
return;
}
$this->attributes[$name] = $value;
}
public function removeAttribute(string $name): void {
unset($this->attributes[$name]);
}
public function appendChild(HtmlTypeInterface $child): HtmlTypeInterface {
return $this->children[] = $child;
}
public function removeChild(HtmlTypeInterface $target): void {
$remove = [];
foreach($this->children as $child)
if($child === $target)
$remove[] = $child;
$this->children = array_diff($this->children, $remove);
}
public function setTextContent(string $textContent): void {
$this->children = [new HtmlText($textContent)];
}
public function getElementsByTagName(string $tagName): array {
$tagName = mb_strtolower($tagName);
$elements = [];
foreach($this->children as $child) {
if($child instanceof HtmlTag) {
$elements = array_merge($elements, $child->getElementsByTagName($tagName));
if(mb_strtolower($child->getTagName()) === $tagName)
$elements[] = $child;
}
}
return $elements;
}
public function getElementsByClassName(string $className): array {
$elements = [];
foreach($this->children as $child) {
if($child instanceof HtmlTag) {
$elements = array_merge($elements, $child->getElementsByClassName($className));
$classList = explode(' ', $child->getAttribute('class') ?? '');
if(in_array($className, $classList))
$elements[] = $child;
}
}
return $elements;
}
public function getElementById(string $idString): ?HtmlTag {
foreach($this->children as $child) {
if($child instanceof HtmlTag) {
if($child->getAttribute('id') == $idString)
return $child;
$element = $child->getElementById($idString);
if($element !== null)
return $element;
}
}
return null;
}
public function asHTML(): string {
$attrs = '';
$children = '';
foreach($this->attributes as $key => $val) {
$attrs .= sprintf(' %s', $key);
if($key !== $val)
$attrs .= sprintf('="%s"', htmlspecialchars($val));
}
if($this->selfClosing)
return sprintf('<%s%s/>', $this->getTagName(), $attrs);
foreach($this->children as $child)
$children .= $child->asHTML();
return sprintf('<%1$s%2$s>%3$s</%1$s>', $this->getTagName(), $attrs, $children);
}
}

14
src/HtmlText.php Normal file
View File

@ -0,0 +1,14 @@
<?php
namespace YTKNS;
class HtmlText implements HtmlTypeInterface {
private string $text = '';
public function __construct(string $text) {
$this->text = $text;
}
public function asHTML(): string {
return htmlspecialchars($this->text);
}
}

View File

@ -0,0 +1,6 @@
<?php
namespace YTKNS;
interface HtmlTypeInterface {
public function asHTML(): string;
}

61
src/PageBuilder.php Normal file
View File

@ -0,0 +1,61 @@
<?php
namespace YTKNS;
final class PageBuilder {
private $tagHtml;
private $tagHead;
private $tagBody;
private $tagTitle;
private $tagContainer;
public function __construct(string $pageTitle) {
$sharedCss = '//' . Config::get('domain.main') . '/assets/shared.css?v=' . hash_file('md5', YTKNS_PUB . '/assets/shared.css');
$sharedJs = '//' . Config::get('domain.main') . '/assets/shared.js?v=' . hash_file('md5', YTKNS_PUB . '/assets/shared.js');
$this->tagHtml = new HtmlTag('html');
$this->tagHtml->appendChild($this->tagHead = new HtmlTag('head'));
$this->tagHtml->appendChild($this->tagBody = new HtmlTag('body'));
$this->tagHead->appendChild(new HtmlTag('meta', ['charset' => 'utf-8'], true));
$this->tagHead->appendChild(new HtmlTag('meta', ['name' => 'viewport', 'content' => 'width=device-width, initial-scale=1'], true));
$this->tagHead->appendChild($this->tagTitle = new HtmlTag('title'));
$this->tagHead->appendChild(new HtmlTag('link', ['href' => $sharedCss, 'type' => 'text/css', 'rel' => 'stylesheet'], true));
$this->tagHead->appendChild(new HtmlTag('script', ['charset' => 'utf-8', 'src' => $sharedJs]));
$this->tagBody->appendChild($this->tagContainer = new HtmlTag('div', ['id' => 'container']));
$this->setTitle($pageTitle);
}
public function getHtml(): HtmlTag {
return $this->tagHtml;
}
public function getHead(): HtmlTag {
return $this->tagHead;
}
public function getBody(): HtmlTag {
return $this->tagBody;
}
public function getContainer(): HtmlTag {
return $this->tagContainer;
}
public function setTitle(string $title): void {
$this->tagTitle->setTextContent($title);
}
public function getElementsByTagName(string $tagName): array {
return $this->tagHtml->getElementsByTagName($tagName);
}
public function getElementsByClassName(string $className): array {
return $this->tagHtml->getElementsByClassName($className);
}
public function getElementById(string $idString): ?HtmlTag {
return $this->tagHtml->getElementById($idString);
}
public function serialise(): string {
return '<!doctype html>' . $this->tagHtml->asHTML();
}
public function __toString(): string {
return $this->serialise();
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace YTKNS;
use Exception;
class PageEffectException extends Exception {};
interface PageEffectInterface {
public function setEffectParams(array $vars, bool $quiet = false): void;
public function getEffectParams(): array;
public function getEffectName(): string;
public function getEffectProperties(): array;
public function applyEffect(PageBuilder $builder): void;
}

40
src/Template.php Normal file
View File

@ -0,0 +1,40 @@
<?php
namespace YTKNS;
final class Template {
private static array $vars = [];
public static function setVar(string $name, $value) {
return $vars[$name] = $value;
}
public static function prefixArray(array $input): array {
$output = [];
foreach($input as $key => $val)
$output[':' . $key] = $val;
return $output;
}
public static function renderRaw(string $path, array $vars = []): string {
$vars = self::prefixArray(array_merge(self::$vars, $vars));
$tpl = file_get_contents(YTKNS_TPL . '/' . $path . '.html');
$tpl = strtr($tpl, $vars);
return $tpl;
}
public static function render(string $path, array $vars = []): void {
echo self::renderRaw($path, $vars);
}
public static function renderSet(string $path, array $varSets = []): string {
$html = '';
$tpl = file_get_contents(YTKNS_TPL . '/' . $path . '.html');
foreach($varSets as $vars)
$html .= strtr($tpl, self::prefixArray($vars));
return $html;
}
}

276
src/Upload.php Normal file
View File

@ -0,0 +1,276 @@
<?php
namespace YTKNS;
use Exception;
class UploadNotFoundException extends Exception {};
class UploadCreationFailedException extends Exception {};
final class Upload {
private const ID_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
public function getId(): string {
return $this->upload_id;
}
public function getPath(): string {
return YTKNS_UPLOADS . '/' . $this->getId();
}
public function getUrl(): string {
return 'https://' . Config::get('domain.main') . '/uploads/' . $this->getId();
}
public function getUserId(): int {
return $this->user_id;
}
public function setUserId(int $userId): void {
$this->user_id = $userId;
}
public function getType(): string {
return $this->upload_type ?? 'text/plain';
}
public function getName(): string {
return $this->upload_name ?? '';
}
public function getHash(): string {
return $this->upload_hash ?? str_pad('', 64, '0');
}
public function getUser(): User {
return User::byId($this->getUserId());
}
public function getUseCount(): int {
return $this->upload_use_count ?? 0;
}
public function getCreated(): int {
return $this->upload_created;
}
public function getlastUsed(): ?int {
return $this->upload_last_used;
}
public function getDeleted(): ?int {
return $this->upload_deleted;
}
public function getDMCA(): ?int {
return $this->upload_dmca;
}
public function delete(bool $hard): void {
if($hard) {
if(is_file($this->getPath()))
unlink($this->getPath());
if($this->getDMCA() < 1) {
$delete = DB::prepare('
DELETE FROM `ytkns_uploads`
WHERE `upload_id` = :id
');
$delete->bindValue('id', $this->getId());
$delete->execute();
}
} else {
$delete = DB::prepare('
UPDATE `ytkns_uploads`
SET `upload_deleted` = NOW()
WHERE `upload_id` = :id
');
$delete->bindValue('id', $this->getId());
$delete->execute();
}
}
public function toJson(bool $asString = false) {
$uploadInfo = [
'id' => $this->getId(),
'name' => $this->getName(),
'type' => $this->getType(),
'user' => $this->getUserId(),
'uses' => $this->getUseCount(),
'hash' => $this->getHash(),
'created' => $this->getCreated(),
'last_used' => $this->getLastUsed(),
'deleted' => $this->getDeleted(),
'dmca' => $this->getDMCA(),
];
if($asString)
$uploadInfo = json_encode($uploadInfo);
return $uploadInfo;
}
public static function generateId(int $length = 16): string {
$token = random_bytes($length);
$chars = strlen(self::ID_CHARS);
for($i = 0; $i < $length; $i++)
$token[$i] = self::ID_CHARS[ord($token[$i]) % $chars];
return $token;
}
public static function create(User $user, string $fileName, string $fileType, string $fileHash): self {
$id = self::generateId();
$create = DB::prepare('
INSERT INTO `ytkns_uploads` (
`upload_id`, `user_id`, `upload_ip`, `upload_name`, `upload_type`, `upload_hash`
) VALUES (
:id, :user, INET6_ATON(:ip), :name, :type, UNHEX(:hash)
)
');
$create->bindValue('id', $id);
$create->bindValue('user', $user->getId());
$create->bindValue('ip', $_SERVER['REMOTE_ADDR']);
$create->bindValue('name', $fileName);
$create->bindValue('type', $fileType);
$create->bindValue('hash', $fileHash);
$create->execute();
try {
return self::byId($id);
} catch(UploadNotFoundException $ex) {
throw new UploadCreationFailedException;
}
}
public static function byId(string $id): self {
$getUpload = DB::prepare('
SELECT `upload_id`, `user_id`, `upload_use_count`, `upload_name`, `upload_type`,
UNIX_TIMESTAMP(`upload_created`) AS `upload_created`,
UNIX_TIMESTAMP(`upload_last_used`) AS `upload_last_used`,
UNIX_TIMESTAMP(`upload_deleted`) AS `upload_deleted`,
UNIX_TIMESTAMP(`upload_dmca`) AS `upload_dmca`,
INET6_NTOA(`upload_ip`) AS `upload_ip`,
LOWER(HEX(`upload_hash`)) AS `upload_hash`
FROM `ytkns_uploads`
WHERE `upload_id` = :id
AND `upload_deleted` IS NULL
');
$getUpload->bindValue('id', $id);
$upload = $getUpload->execute() ? $getUpload->fetchObject(self::class) : false;
if(!$upload)
throw new UploadNotFoundException;
return $upload;
}
public static function byHash(string $hash): ?self {
$getUpload = DB::prepare('
SELECT `upload_id`, `user_id`, `upload_use_count`, `upload_name`, `upload_type`,
UNIX_TIMESTAMP(`upload_created`) AS `upload_created`,
UNIX_TIMESTAMP(`upload_last_used`) AS `upload_last_used`,
UNIX_TIMESTAMP(`upload_deleted`) AS `upload_deleted`,
UNIX_TIMESTAMP(`upload_dmca`) AS `upload_dmca`,
INET6_NTOA(`upload_ip`) AS `upload_ip`,
LOWER(HEX(`upload_hash`)) AS `upload_hash`
FROM `ytkns_uploads`
WHERE `upload_hash` = UNHEX(:hash)
AND `upload_deleted` IS NULL
');
$getUpload->bindValue('hash', $hash);
$upload = $getUpload->execute() ? $getUpload->fetchObject(self::class) : false;
return $upload ? $upload : null;
}
public static function deleted(): array {
$getDeleted = DB::prepare('
SELECT `upload_id`, `user_id`, `upload_use_count`, `upload_name`, `upload_type`,
UNIX_TIMESTAMP(`upload_created`) AS `upload_created`,
UNIX_TIMESTAMP(`upload_last_used`) AS `upload_last_used`,
UNIX_TIMESTAMP(`upload_deleted`) AS `upload_deleted`,
UNIX_TIMESTAMP(`upload_dmca`) AS `upload_dmca`,
INET6_NTOA(`upload_ip`) AS `upload_ip`,
LOWER(HEX(`upload_hash`)) AS `upload_hash`
FROM `ytkns_uploads`
WHERE `upload_deleted` IS NOT NULL
OR `upload_dmca` IS NOT NULL
');
if(!$getDeleted->execute())
return [];
$deleted = [];
while($upload = $getDeleted->fetchObject(self::class))
$deleted[] = $upload;
return $deleted;
}
public static function purgeOrphans(): void {
DB::exec('
UPDATE `ytkns_uploads`
SET `upload_deleted` = NOW()
WHERE `upload_use_count` < 1
AND (`upload_created` + INTERVAL 1 DAY) < NOW()
AND `upload_dmca` IS NULL
');
$orphans = self::deleted();
foreach($orphans as $orphan)
$orphan->delete(true);
}
public static function resync(array $uploadFields): void {
if(empty($uploadFields))
return;
$effectNames = array_keys($uploadFields);
$fetchEffectsWhereIn = range(0, count($effectNames) - 1);
array_walk($fetchEffectsWhereIn, function(&$i, $k, $v) { $i = $v . $i; }, ':field_');
$fetchEffects = DB::prepare(sprintf('
SELECT `effect_name`, `effect_params`
FROM `ytkns_zones_effects`
WHERE `effect_name` IN (%s)
', implode(', ', $fetchEffectsWhereIn)));
foreach($fetchEffectsWhereIn as $index => $name)
$fetchEffects->bindValue($name, $effectNames[$index]);
$effectsWithUploads = $fetchEffects->execute() ? $fetchEffects->fetchAll(\PDO::FETCH_OBJ) : false;
$effectsWithUploads = $effectsWithUploads ? $effectsWithUploads : [];
$uploadUseCounts = [];
foreach($effectsWithUploads as $effectWithUploads) {
if(!array_key_exists($effectWithUploads->effect_name, $uploadFields))
continue;
$effectParams = json_decode($effectWithUploads->effect_params, true);
foreach($uploadFields[$effectWithUploads->effect_name] as $uploadField) {
if(empty($uploadId = $effectParams[$uploadField]))
continue;
if(!isset($uploadUseCounts[$uploadId]))
$uploadUseCounts[$uploadId] = 1;
else
$uploadUseCounts[$uploadId] += 1;
}
}
$updateUploadUseCount = DB::prepare('
UPDATE `ytkns_uploads`
SET `upload_use_count` = :count
WHERE `upload_id` = :id
');
DB::exec('UPDATE `ytkns_uploads` SET `upload_use_count` = 0');
foreach($uploadUseCounts as $uploadId => $uploadUseCount) {
$updateUploadUseCount->bindValue('id', $uploadId);
$updateUploadUseCount->bindValue('count', $uploadUseCount);
$updateUploadUseCount->execute();
}
}
}

123
src/User.php Normal file
View File

@ -0,0 +1,123 @@
<?php
namespace YTKNS;
use Exception;
class UserNotFoundException extends Exception {}
class UserCreationFailedException extends Exception {}
class UserCreationInvalidNameException extends Exception {}
class UserCreationInvalidPasswordException extends Exception {}
class UserCreationInvalidMailException extends Exception {}
class User {
public function __construct() {
}
public function getId(): int {
return $this->user_id ?? 0;
}
private function setId(int $userId): void {
$this->user_id = $userId;
}
public function getUsername(): string {
return $this->username ?? '';
}
public static function byId(int $userId): self {
$getUser = DB::prepare('
SELECT `user_id`, `username`, `email`, `password`, `user_created`
FROM `ytkns_users`
WHERE `user_id` = :user
');
$getUser->bindValue('user', $userId);
$getUser->execute();
$user = $getUser->fetchObject(self::class);
if($user === false)
throw new UserNotFoundException;
return $user;
}
public static function forLogin(string $usernameOrEmail): self {
$getUser = DB::prepare('
SELECT `user_id`, `username`, `email`, `password`, `user_created`
FROM `ytkns_users`
WHERE LOWER(`username`) = LOWER(:username)
OR LOWER(`email`) = LOWER(:email)
');
$getUser->bindValue('username', $usernameOrEmail);
$getUser->bindValue('email', $usernameOrEmail);
$getUser->execute();
$user = $getUser->fetchObject(self::class);
if($user === false)
throw new UserNotFoundException;
return $user;
}
public static function forProfile(string $username): self {
$getUser = DB::prepare('
SELECT `user_id`, `username`, `email`, `password`, `user_created`
FROM `ytkns_users`
WHERE LOWER(`username`) = LOWER(:username)
');
$getUser->bindValue('username', $username);
$getUser->execute();
$user = $getUser->fetchObject(self::class);
if($user === false)
throw new UserNotFoundException;
return $user;
}
public static function validatePassword(string $password): bool {
$chars = [];
$length = mb_strlen($password);
for($i = 0; $i < $length; $i++) {
$current = mb_substr($password, $i, 1);
if(!in_array($current, $chars, true))
$chars[] = $current;
}
return count($chars) >= 6;
}
public static function hashPassword(string $password): string {
return password_hash($password, PASSWORD_ARGON2ID);
}
public static function create(string $userName, string $password, string $email): self {
if(!preg_match('#^([a-zA-Z0-9-_]{1,20})$#', $userName))
throw new UserCreationInvalidNameException;
if(!filter_var($email, FILTER_VALIDATE_EMAIL))
throw new UserCreationInvalidMailException;
if(!self::validatePassword($password))
throw new UserCreationInvalidPasswordException;
$password = self::hashPassword($password);
$createUser = DB::prepare('
INSERT INTO `ytkns_users` (
`username`, `email`, `password`
) VALUES (
:username, :email, :password
)
');
$createUser->bindValue('username', $userName);
$createUser->bindValue('email', $email);
$createUser->bindValue('password', $password);
$userId = $createUser->execute() ? (int)DB::lastInsertId() : 0;
try {
return self::byId($userId);
} catch(UserNotFoundException $ex) {
throw new UserCreationFailedException;
}
}
}

122
src/UserInvite.php Normal file
View File

@ -0,0 +1,122 @@
<?php
namespace YTKNS;
use Exception;
class UserInviteNotFoundException extends Exception {}
class UserInviteCreationFailedException extends Exception {}
final class UserInvite {
private const TOKEN_LENGTH = 16;
public function getToken(): string {
return $this->invite_token;
}
public function getCreatorId(): int {
return $this->created_by ?? 0;
}
public function getCreator(): User {
return User::byId($this->getCreatedById());
}
public function getUserId(): ?int {
return $this->used_by ?? null;
}
public function getUser(): ?User {
$userId = $this->getUserId();
if($userId === null)
return null;
try {
return User::byId($this->getUserId());
} catch(UserNotFoundException $ex) {
return null;
}
}
public function getCreated(): int {
return $this->invite_created ?? 0;
}
public function getUsed(): ?int {
return $this->invite_used ?? null;
}
public function isUsed(): bool {
return !empty($this->used_by) || !empty($this->invite_used);
}
public function markUsed(User $user): void {
$markUsed = DB::prepare('
UPDATE `ytkns_users_invites`
SET `used_by` = :user
WHERE `invite_token` = UNHEX(:token)
');
$markUsed->bindValue('user', $user->getId());
$markUsed->bindValue('token', $this->getToken());
$markUsed->execute();
}
public static function fromCreator(User $creator): array {
$getInvites = DB::prepare('
SELECT `created_by`, `used_by`,
LOWER(HEX(`invite_token`)) AS `invite_token`,
UNIX_TIMESTAMP(`invite_created`) AS `invite_created`,
UNIX_TIMESTAMP(`invite_used`) AS `invite_used`
FROM `ytkns_users_invites`
WHERE `created_by` = :creator
');
$getInvites->bindValue('creator', $creator->getId());
$getInvites->execute();
$invites = [];
while($invite = $getInvites->fetchObject(self::class))
$invites[] = $invite;
return $invites;
}
public static function byToken(string $token, bool $isHex = true): self {
$getInvite = DB::prepare(sprintf('
SELECT `created_by`, `used_by`,
LOWER(HEX(`invite_token`)) AS `invite_token`,
UNIX_TIMESTAMP(`invite_created`) AS `invite_created`,
UNIX_TIMESTAMP(`invite_used`) AS `invite_used`
FROM `ytkns_users_invites`
WHERE `invite_token` = %s(:token)
', $isHex ? 'UNHEX' : ''));
$getInvite->bindValue('token', $token);
$invite = $getInvite->execute() ? $getInvite->fetchObject(self::class) : false;
if(!$invite)
throw new UserInviteNotFoundException;
return $invite;
}
public static function generateToken(): string {
return bin2hex(random_bytes(self::TOKEN_LENGTH));
}
public static function create(User $creator): self {
$inviteToken = self::generateToken();
$insertInvite = DB::prepare('
INSERT INTO `ytkns_users_invites` (
`invite_token`, `created_by`
) VALUES (
UNHEX(:token), :creator
)
');
$insertInvite->bindValue('token', $inviteToken);
$insertInvite->bindValue('creator', $creator->getId());
$insertInvite->execute();
try {
return self::byToken($inviteToken);
} catch(UserInviteNotFoundException $ex) {
throw new UserInviteCreationFailedException;
}
}
}

149
src/UserSession.php Normal file
View File

@ -0,0 +1,149 @@
<?php
namespace YTKNS;
use Exception;
final class UserSessionNotFoundException extends Exception {};
final class UserSessionCreatedFailedException extends Exception {};
class UserSession {
private const TOKEN_CHARS = 'abcdefghijklmnopqrstuvwxyz-0123456789_ABCDEFGHIJKLMNOPQRSTUVWXYZ';
private static $instance = null;
public static function instance(): ?self {
return self::$instance;
}
public function setInstance(): void {
self::$instance = $this;
}
public static function hasInstance(): bool {
return !empty(self::$instance->session_token);
}
public static function unsetInstance(): void {
self::$instance = null;
}
public function __construct() {
}
public function getUserId(): int {
return $this->user_id ?? 0;
}
public function getUser(): User {
return User::byId($this->getUserId());
}
public function getToken(): string {
return $this->session_token;
}
public function getSmallToken(int $rounds = 5, int $length = 8, int $offset = 0): string {
$token = $this->getToken();
$tokenLength = strlen($token) - $length;
for($i = 0; $i < $rounds; $i++)
$offset = ord($token[$offset]) % $tokenLength;
return str_rot13(substr($token, $offset, $length));
}
public function getCreated(): int {
return $this->session_created ?? 0;
}
public function getExpires(): int {
return $this->session_expires ?? 0;
}
public function getBump(): bool {
return $this->session_bump ?? false;
}
public function getFirstIp(): string {
return $this->session_ip_first ?? '';
}
public function getLastIp(): ?string {
return $this->session_ip_last ?? null;
}
public function update(): void {
$update = DB::prepare('
UPDATE `ytkns_users_sessions`
SET `session_expires` = IF(:bump, NOW() + INTERVAL 1 MONTH, `session_expires`),
`session_ip_last` = INET6_ATON(:ip),
`session_used` = NOW()
WHERE `session_token` = :token
');
$update->bindValue('bump', $this->getBump() ? 1 : 0);
$update->bindValue('ip', $_SERVER['REMOTE_ADDR']);
$update->bindValue('token', $this->getToken());
$update->execute();
}
public function destroy(): void {
$destroy = DB::prepare('
DELETE FROM `ytkns_users_sessions`
WHERE `session_token` = :token
');
$destroy->bindValue('token', $this->getToken());
$destroy->execute();
}
public static function generateToken(int $length = 64): string {
$token = random_bytes($length);
$chars = strlen(self::TOKEN_CHARS);
for($i = 0; $i < $length; $i++)
$token[$i] = self::TOKEN_CHARS[ord($token[$i]) % $chars];
return $token;
}
public static function create(User $user, bool $bump = false): self {
$token = self::generateToken();
$create = DB::prepare('
INSERT INTO `ytkns_users_sessions` (
`user_id`, `session_token`, `session_ip_first`, `session_bump`
) VALUES (
:user, :token, INET6_ATON(:ip), :bump
)
');
$create->bindValue('user', $user->getId());
$create->bindValue('token', $token);
$create->bindValue('ip', $_SERVER['REMOTE_ADDR']);
$create->bindValue('bump', $bump ? 1 : 0);
$create->execute();
try {
return self::byToken($token);
} catch(UserSessionNotFoundException $ex) {
throw new UserSessionCreatedFailedException;
}
}
public static function byToken(string $token): self {
$getSession = DB::prepare('
SELECT `user_id`, `session_token`, `session_bump`,
UNIX_TIMESTAMP(`session_created`) AS `session_created`,
UNIX_TIMESTAMP(`session_expires`) AS `session_expires`,
INET6_NTOA(`session_ip_first`) AS `session_ip_first`,
INET6_NTOA(`session_ip_last`) AS `session_ip_last`
FROM `ytkns_users_sessions`
WHERE `session_token` = :token
');
$getSession->bindValue('token', $token);
$session = $getSession->execute() ? $getSession->fetchObject(self::class) : false;
if(!$session)
throw new UserSessionNotFoundException;
return $session;
}
public static function purge(): void {
DB::exec('
DELETE FROM `ytkns_users_sessions`
WHERE `session_expires` <= NOW()
');
}
}

361
src/Zone.php Normal file
View File

@ -0,0 +1,361 @@
<?php
namespace YTKNS;
use Exception;
class ZoneNotFoundException extends Exception {};
class ZoneCreationFailedException extends Exception {};
class ZoneInvalidIdException extends Exception {};
class ZoneInvalidNameException extends Exception {};
class ZoneInvalidTitleException extends Exception {};
final class Zone {
private $effects = null;
private $passiveMode = false;
public function __construct() {
}
public function setPassiveMode(): void {
$this->passiveMode = true;
if($this->effects === null)
$this->effects = [];
}
public function getId(): int {
return $this->zone_id ?? 0;
}
public function hasId(): bool {
return isset($this->zone_id) && $this->zone_id > 0;
}
private function setId(int $id): void {
if($id < 1)
throw new ZoneInvalidIdException;
$this->zone_id = $id;
}
public function getUserId(): int {
return $this->user_id ?? 0;
}
public function setUserId(int $userId): void {
$this->user_id = $userId;
}
private $userObj = null;
public function getUser(): User {
if($this->userObj === null)
$this->userObj = User::byId($this->getUserId());
return $this->userObj;
}
public function getName(): string {
return $this->zone_name;
}
public function setName(string $name): void {
if(!self::validName($name))
throw new ZoneInvalidNameException;
$this->zone_name = $name;
}
public function getUrl(): string {
return 'https://' . sprintf(Config::get('domain.zone'), $this->getName());
}
public function getUrlForPreview(): string {
return $this->getUrl() . '?preview=1';
}
public function getScreenshotPath(): string {
return YTKNS_SCREENSHOTS . '/' . $this->getName() . '.jpg';
}
public function getScreenshotUrl(): string {
return 'https://' . Config::get('domain.main') . '/ss/' . $this->getName() . '.jpg';
}
public function takeScreenshot(): void {
$path = escapeshellarg($this->getScreenshotPath());
$url = escapeshellarg($this->getUrlForPreview());
system(sprintf('/usr/bin/firefox --window-size=800,600 --screenshot %s %s', $path, $url));
//system(sprintf('/usr/bin/convert %1$s 200x150 %1$s', $path));
}
public function removeScreenshot(): void {
$path = $this->getScreenshotPath();
if(is_file($path))
unlink($path);
}
public function getTitle(): string {
return $this->zone_title;
}
public function setTitle(string $title): void {
if(strlen($title) > 255)
throw new ZoneInvalidTitleException;
$this->zone_title = $title;
}
public function getViews(): int {
return $this->zone_views ?? 0;
}
public function incrementViews(): void {
$updateViews = DB::prepare('
UPDATE `ytkns_zones`
SET `zone_views` = `zone_views` + 1
WHERE `zone_id` = :zone
');
$updateViews->bindValue('zone', $this->getId());
if($updateViews->execute())
$this->zone_views = $this->getViews() + 1;
}
public function getEffects(): array {
if(!is_array($this->effects))
$this->effects = $this->hasId() ? ZoneEffect::byZone($this) : [];
return $this->effects;
}
public function getPageBuilder(bool $quiet = false): PageBuilder {
$pageBuilder = new PageBuilder($this->getTitle());
$effects = $this->getEffects();
foreach($effects as $effect)
$effect->applyEffect($pageBuilder, $quiet);
return $pageBuilder;
}
public function addEffect(PageEffectInterface $effect): void {
if(is_array($this->effects))
$this->effects[] = $effect;
if(!$this->passiveMode)
$this->addEffectDatabase($effect);
}
private function addEffectDatabase(PageEffectInterface $effect): void {
if(!$this->hasId())
return;
$insert = DB::prepare('
REPLACE INTO `ytkns_zones_effects` (
`zone_id`, `effect_name`, `effect_params`
) VALUES (
:zone, :name, :params
)
');
$insert->bindValue('zone', $this->getId());
$insert->bindValue('name', substr(get_class($effect), 14, -6));
$insert->bindValue('params', json_encode($effect->getEffectParams()));
$insert->execute();
}
public function removeEffects(): void {
$this->effects = [];
if(!$this->passiveMode)
$this->removeEffectsDatabase();
}
private function removeEffectsDatabase(): void {
if(!$this->hasId())
return;
$removeEffects = DB::prepare('
DELETE FROM `ytkns_zones_effects`
WHERE `zone_id` = :zone
');
$removeEffects->bindValue('zone', $this->getId());
$removeEffects->execute();
}
public static function byId(string $id): self {
$getZone = DB::prepare('
SELECT `zone_id`, `user_id`, `zone_name`, `zone_title`, `zone_views`,
UNIX_TIMESTAMP(`zone_created`) AS `zone_created`,
UNIX_TIMESTAMP(`zone_updated`) AS `zone_updated`
FROM `ytkns_zones`
WHERE `zone_id` = :zone
');
$getZone->bindValue('zone', $id);
$zone = $getZone->execute() ? $getZone->fetchObject(self::class) : false;
if(!$zone)
throw new ZoneNotFoundException;
return $zone;
}
public static function byName(string $name): self {
$getZone = DB::prepare('
SELECT `zone_id`, `user_id`, `zone_name`, `zone_title`, `zone_views`,
UNIX_TIMESTAMP(`zone_created`) AS `zone_created`,
UNIX_TIMESTAMP(`zone_updated`) AS `zone_updated`
FROM `ytkns_zones`
WHERE `zone_name` = :name
');
$getZone->bindValue('name', $name);
$zone = $getZone->execute() ? $getZone->fetchObject(self::class) : false;
if(!$zone)
throw new ZoneNotFoundException;
return $zone;
}
public static function byUser(User $user, ?string $orderBy = null, bool $ascending = true, int $take = 0, int $offset = 0): array {
$getZonesQuery = '
SELECT `zone_id`, `user_id`, `zone_name`, `zone_title`, `zone_views`,
UNIX_TIMESTAMP(`zone_created`) AS `zone_created`,
UNIX_TIMESTAMP(`zone_updated`) AS `zone_updated`
FROM `ytkns_zones`
WHERE `user_id` = :user
';
if($orderBy !== null)
$getZonesQuery .= sprintf('ORDER BY `%s` %s', $orderBy, $ascending ? 'ASC' : 'DESC');
if($take > 0)
$getZonesQuery .= sprintf(' LIMIT %d OFFSET %d', $take, $offset);
$getZones = DB::prepare($getZonesQuery);
$getZones->bindValue('user', $user->getId());
$getZones->execute();
$zones = [];
while($zone = $getZones->fetchObject(self::class))
$zones[] = $zone;
return $zones;
}
public static function all(?string $orderBy = null, bool $ascending = true, int $take = 0, int $offset = 0): array {
$getZonesQuery = '
SELECT `zone_id`, `user_id`, `zone_name`, `zone_title`, `zone_views`,
UNIX_TIMESTAMP(`zone_created`) AS `zone_created`,
UNIX_TIMESTAMP(`zone_updated`) AS `zone_updated`
FROM `ytkns_zones`
';
if($orderBy !== null)
$getZonesQuery .= sprintf(' ORDER BY `%s` %s', $orderBy, $ascending ? 'ASC' : 'DESC');
if($take > 0)
$getZonesQuery .= sprintf(' LIMIT %d OFFSET %d', $take, $offset);
$getZones = DB::prepare($getZonesQuery);
$getZones->execute();
$zones = [];
while($zone = $getZones->fetchObject(self::class))
$zones[] = $zone;
return $zones;
}
public static function count(): int {
$getZoneCount = DB::prepare('
SELECT COUNT(`zone_id`)
FROM `ytkns_zones`
');
return (int)($getZoneCount->execute() ? $getZoneCount->fetchColumn() : 0);
}
public static function create(User $user, string $name, string $title): self {
$create = DB::prepare('
INSERT INTO `ytkns_zones` (
`user_id`, `zone_name`, `zone_title`
) VALUES (
:user, LOWER(:name), :title
)
');
$create->bindValue('user', $user->getId());
$create->bindValue('name', $name);
$create->bindValue('title', $title);
$create->execute();
try {
return self::byName($name);
} catch(ZoneNotFoundException $ex) {
throw new ZoneCreationFailedException;
}
}
public function update(array $save = ['user_id', 'zone_name', 'zone_title']): void {
if(!$this->hasId())
return;
$set = [
'`zone_updated` = NOW()',
];
foreach($save as $param)
$set[] = sprintf('`%1$s` = :%1$s', $param);
$update = DB::prepare(sprintf('UPDATE `ytkns_zones` SET %s WHERE `zone_id` = :zone', implode(', ', $set)));
if(in_array('user_id', $save))
$update->bindValue('user_id', $this->getUserId());
if(in_array('zone_name', $save))
$update->bindValue('zone_name', $this->getName());
if(in_array('zone_title', $save))
$update->bindValue('zone_title', $this->getTitle());
$update->bindValue('zone', $this->getId());
$update->execute();
if($this->passiveMode) {
$effects = $this->getEffects();
$this->removeEffectsDatabase();
foreach($this->getEffects() as $effectInfo)
$this->addEffectDatabase($effectInfo);
}
}
public static function exists(string $name): bool {
$check = DB::prepare('
SELECT COUNT(`zone_name`) > 0
FROM `ytkns_zones`
WHERE `zone_name` = :name
');
$check->bindValue('name', $name);
return $check->execute() ? (bool)$check->fetchColumn() : true;
}
public function delete(): void {
$delete = DB::prepare('
DELETE FROM `ytkns_zones`
WHERE `zone_id` = :zone
');
$delete->bindValue('zone', $this->getId());
$delete->execute();
}
public static function validName(string $name): bool {
return preg_match('#^([A-Za-z0-9]{1,50})$#', $name);
}
public function toJson(bool $asString = false) {
$zoneInfo = [
'id' => $this->getId(),
'name' => $this->getName(),
'title' => $this->getTitle(),
'effects' => [],
];
foreach($this->getEffects() as $effect)
$zoneInfo['effects'][] = $effect->toJson();
if($asString)
$zoneInfo = json_encode($zoneInfo);
return $zoneInfo;
}
public function queueTask(string $task, ...$params): void {
ZoneTask::enqueue($this, $task, $params);
}
}

102
src/ZoneEffect.php Normal file
View File

@ -0,0 +1,102 @@
<?php
namespace YTKNS;
use Exception;
class ZoneEffectClassNotFoundException extends Exception {};
final class ZoneEffect {
private const CLASS_NAME = '\\YTKNS\\Effects\\%sEffect';
public function __construct() {
}
public function getZoneId(): int {
return $this->zone_id ?? 0;
}
public function getZone(): Zone {
return Zone::byId($this->getZoneId());
}
public function setZoneId(Zone $zone): void {
$this->zone_id = $zone->getId();
}
public function getEffectName(): string {
return $this->effect_name;
}
public function setEffectName(string $name): void {
$this->effect_name = $name;
}
public function getEffectParams(): array {
if(!isset($this->effect_params) || $this->effect_params === null)
return [];
if(is_string($this->effect_params))
$this->effect_params = json_decode($this->effect_params, true);
return $this->effect_params;
}
public function setEffectParams(array $params): void {
$this->effect_params = $params;
}
public static function effectClassName(string $name): string {
return sprintf(self::CLASS_NAME, $name);
}
public function getEffectClassName(): string {
return self::effectClassName($this->getEffectName());
}
public static function effectClass(string $name) {
$className = self::effectClassName($name);
if(!class_exists($className))
throw new ZoneEffectClassNotFoundException($name);
return new $className;
}
public function getEffectClass(bool $quiet = false) {
try {
$effect = self::effectClass($this->getEffectName());
$effect->setEffectParams($this->getEffectParams(), $quiet);
return $effect;
} catch(ZoneEffectClassNotFoundException $ex) {
return null;
}
}
public function applyEffect(PageBuilder $builder, bool $quiet = false): void {
$effect = $this->getEffectClass($quiet);
if($effect !== null)
$effect->applyEffect($builder);
}
public static function byZone(Zone $zone): array {
$getEffects = DB::prepare('
SELECT `zone_id`, `effect_name`, `effect_params`
FROM `ytkns_zones_effects`
WHERE `zone_id` = :zone
');
$getEffects->bindValue('zone', $zone->getId());
if(!$getEffects->execute())
return [];
$effects = [];
while($effect = $getEffects->fetchObject(self::class))
$effects[] = $effect;
return $effects;
}
public function toJson(bool $asString = false) {
$effectInfo = [
'type' => $this->getEffectName(),
'values' => $this->getEffectParams(),
];
if($asString)
$effectInfo = json_encode($effectInfo);
return $effectInfo;
}
}

46
src/ZoneRedirect.php Normal file
View File

@ -0,0 +1,46 @@
<?php
namespace YTKNS;
final class ZoneRedirect {
public function __construct() {
}
public static function exists(string $subdomain): bool {
$check = DB::prepare('
SELECT COUNT(`redirect_name`) > 0
FROM `ytkns_redirects`
WHERE `redirect_name` = :subdomain
');
$check->bindValue('subdomain', $subdomain);
return $check->execute() ? (bool)$check->fetchColumn() : true;
}
public static function find(string $subdomain): ?ZoneRedirect {
$find = DB::prepare('
SELECT `redirect_name`, `redirect_target`
FROM `ytkns_redirects`
WHERE `redirect_name` = :subdomain
');
$find->bindValue('subdomain', $subdomain);
$redirect = $find->execute() ? $find->fetchObject(self::class) : false;
return $redirect ? $redirect : null;
}
public static function create(string $subdomain, string $target): void {
$create = DB::prepare('
REPLACE INTO `ytkns_redirects` (
`redirect_name`, `redirect_target`
) VALUES (
:name, :target
)
');
$create->bindValue('name', $subdomain);
$create->bindValue('target', $target);
$create->execute();
}
public function execute(): void {
http_response_code(301);
header(sprintf('Location: %s', $this->redirect_target));
}
}

60
src/ZoneTask.php Normal file
View File

@ -0,0 +1,60 @@
<?php
namespace YTKNS;
final class ZoneTask {
public function getZoneId(): int {
return $this->zone_id ?? 0;
}
public function getZone(): Zone {
return Zone::byId($this->getZoneId());
}
public function getName(): string {
return $this->task_name ?? '';
}
public function getParams(): array {
if(empty($this->task_params))
return [];
return unserialize($this->task_params);
}
public function delete(): void {
$deleteTask = DB::prepare('
DELETE FROM `ytkns_zones_tasks`
WHERE `zone_id` = :zone
AND `task_name` = :task
');
$deleteTask->bindValue('zone', $this->getZoneId());
$deleteTask->bindValue('task', $this->getName());
$deleteTask->execute();
}
public static function queue(): array {
$getTasks = DB::prepare('
SELECT `zone_id`, `task_name`, `task_params`
FROM `ytkns_zones_tasks`
');
$getTasks->execute();
$tasks = [];
while($task = $getTasks->fetchObject(self::class))
$tasks[] = $task;
return $tasks;
}
public static function enqueue(Zone $zone, string $name, array $params = []): void {
$enqueue = DB::prepare('
REPLACE INTO `ytkns_zones_tasks` (
`zone_id`, `task_name`, `task_params`
) VALUES (
:zone, :task, :params
)
');
$enqueue->bindValue('zone', $zone->getId());
$enqueue->bindValue('task', $name);
$enqueue->bindValue('params', serialize(array_values($params)));
$enqueue->execute();
}
}

44
src/ZoneView.php Normal file
View File

@ -0,0 +1,44 @@
<?php
namespace YTKNS;
final class ZoneView {
private const THRESHOLD = 60 * 60 * 24;
public function getTime(): int {
return $this->view_time ?? 0;
}
public static function byZoneAddress(Zone $zone, string $ipAddress): ?self {
$getZoneView = DB::prepare('
SELECT `zone_id`,
UNIX_TIMESTAMP(`view_time`) AS `view_time`,
INET6_NTOA(`view_address`) AS `view_address`
FROM `ytkns_zones_views`
WHERE `zone_id` = :zone
AND `view_address` = INET6_ATON(:ip)
');
$getZoneView->bindValue('zone', $zone->getId());
$getZoneView->bindValue('ip', $ipAddress);
$zoneView = $getZoneView->execute() ? $getZoneView->fetchObject(self::class) : false;
return $zoneView ? $zoneView : null;
}
public static function increment(Zone $zone, string $ipAddress): void {
$zoneView = self::byZoneAddress($zone, $ipAddress);
if($zoneView !== null && ($zoneView->getTime() + self::THRESHOLD) > time())
return;
$updateZoneView = DB::prepare('
REPLACE INTO `ytkns_zones_views` (
`view_address`, `zone_id`, `view_time`
) VALUES (
INET6_ATON(:ip), :zone, NOW()
)
');
$updateZoneView->bindValue('ip', $ipAddress);
$updateZoneView->bindValue('zone', $zone->getId());
if($updateZoneView->execute())
$zone->incrementViews();
}
}

74
startup.php Normal file
View File

@ -0,0 +1,74 @@
<?php
namespace YTKNS;
define('YTKNS_STARTUP', microtime(true));
define('YTKNS_ROOT', __DIR__);
define('YTKNS_SRC', YTKNS_ROOT . '/src');
define('YTKNS_TPL', YTKNS_ROOT . '/templates');
define('YTKNS_UPLOADS', YTKNS_ROOT . '/uploads');
define('YTKNS_PUB', YTKNS_ROOT . '/public');
define('YTKNS_SCREENSHOTS', YTKNS_PUB . '/ss');
define('YTKNS_DEBUG', is_file(YTKNS_ROOT . '/.debug'));
require_once __DIR__ . '/config.php';
define('ALLOWED_UPLOADS', [
'audio/mpeg', 'application/x-font-gdos', 'audio/ogg', 'application/ogg',
'image/jpeg', 'image/png', 'image/gif',
]);
define('SITE_EFFECTS', [
\YTKNS\Effects\BackgroundAudioEffect::class,
\YTKNS\Effects\BackgroundImageEffect::class,
\YTKNS\Effects\ForegroundImageEffect::class,
\YTKNS\Effects\ZoomTextEffect::class,
\YTKNS\Effects\NewlyCreatedPageEffect::class,
]);
define('EFFECT_UPLOADS', [
'BackgroundAudio' => ['ogg', 'mp3'],
'BackgroundImage' => ['img'],
'ForegroundImage' => ['img'],
]);
error_reporting(YTKNS_DEBUG ? -1 : 0);
ini_set('display_errors', YTKNS_DEBUG ? 'On' : 'Off');
mb_internal_encoding('utf-8');
date_default_timezone_set('utc');
// Display exception report
set_exception_handler(function(\Throwable $ex) {
http_response_code(500);
$out = file_get_contents(YTKNS_TPL . (YTKNS_DEBUG ? '/debug/index.html' : '/errors/500.html'));
echo strtr($out, [
':raw_ex' => (string)$ex,
':type' => get_class($ex),
':msg' => $ex->getMessage(),
]);
exit;
});
// Turn uncaught errors into uncaught exceptions.
set_error_handler(function(int $errno, string $errstr, string $errfile, int $errline) {
throw new \ErrorException($errstr, 0, $errno, $errfile, $errline);
return true;
}, -1);
// Register class autoloader
spl_autoload_register(function(string $className) {
if(substr($className, 0, 6) !== 'YTKNS\\')
return;
$classPath = YTKNS_SRC . str_replace('\\', '/', substr($className, 5)) . '.php';
if(is_file($classPath))
require_once $classPath;
});
DB::init(PDO_DSN, PDO_USER, PDO_PASS, DB::FLAGS);
Config::init();
Config::setDefault('user.invite_only', true);
Config::setDefault('domain.main', 'ytkns.com');
Config::setDefault('domain.zone', '%s.ytkns.com');

View File

@ -0,0 +1,8 @@
<label class="auth-field">
<div class="auth-field-label">
:field_title
</div>
<div class="auth-field-value">
<input type=":field_type" name=":field_name" class="auth-field-value-input" value=":field_value"/>
</div>
</label>

11
templates/auth/login.html Normal file
View File

@ -0,0 +1,11 @@
<h1 class="page-title">Log in</h1>
<form action="/auth/login" method="post" class="auth">
:auth_error
:auth_fields
<label class="auth-option">
<input type="checkbox" name="remember" class="auth-option-input" :auth_remember/> Stay logged in
</label>
<div class="auth-buttons">
<input type="submit" value="Log in" class="auth-buttons-button"/>
</div>
</form>

View File

@ -0,0 +1,17 @@
<h1 class="page-title">Log in</h1>
<form action="/auth/fid" method="post" class="auth auth-fid">
:auth_error
<div class="auth-buttons">
<input type="submit" value="Log in with Flashii ID" class="auth-buttons-button auth-buttons-button-fid"/>
<input type="button" value="Log in with YTKNS details" class="auth-buttons-button" onclick="document.getElementById('_login').classList.remove('hidden');this.classList.add('hidden');" />
</div>
</form>
<form action="/auth/login" method="post" class="auth hidden" id="_login">
:auth_fields
<label class="auth-option">
<input type="checkbox" name="remember" class="auth-option-input" :auth_remember/> Stay logged in
</label>
<div class="auth-buttons">
<input type="submit" value="Log in" class="auth-buttons-button"/>
</div>
</form>

View File

@ -0,0 +1,8 @@
<h1 class="page-title">Register</h1>
<form action="/auth/register" method="post" class="auth">
:auth_error
:auth_fields
<div class="auth-buttons">
<input type="submit" value="Create account" class="auth-buttons-button"/>
</div>
</form>

View File

View File

@ -0,0 +1,15 @@
<!doctype html>
<!--
:raw_ex
-->
<html>
<head>
<meta charset="utf-8"/>
<title>:type - :msg</title>
</head>
<body>
<pre>
:raw_ex
</pre>
</body>
</html>

3
templates/error.html Normal file
View File

@ -0,0 +1,3 @@
<div class="error">
:error_text
</div>

11
templates/errors/500.html Normal file
View File

@ -0,0 +1,11 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<title>Error 500</title>
</head>
<body>
<h1>ytkns is fucking dead</h1>
<p>:msg</p>
</body>
</html>

9
templates/footer.html Normal file
View File

@ -0,0 +1,9 @@
</div>
<div class="footer" id="ytkns-footer">
YTKNS by <a href="//flash.moe">Flashwave</a> 2019-:footer_year |
<a href="#" onclick="alert(['e&', '.m&o', 'h', 's', 'la', '#&f', 's', 't&kn', 'y', '+&&', 'e', '&m'].reverse().join('&').replace(/&/g, '').replace('#', '@'));">Contact</a> |
Loaded in :footer_took seconds
</div>
</div>:scripts
</body>
</html>

34
templates/header.html Normal file
View File

@ -0,0 +1,34 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>:title</title>
<link href="/assets/style.css" type="text/css" rel="stylesheet"/>
<script type="text/javascript">
var _paq = window._paq || [];
_paq.push(['disableCookies']);
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function() {
_paq.push(['setTrackerUrl', '//uiharu.railgun.sh/mtm']);
_paq.push(['setSiteId', 'zlwmGOjMBk5J']);
var g = document.createElement('script');
g.type = 'text/javascript'; g.async = true;
g.defer = true; g.src = '//uiharu.railgun.sh/mtm.js';
document.head.appendChild(g);
})();
</script>:head
</head>
<body>
<div class="wrapper">
<div class="header">
<div class="header-usermenu">
:menu_user
</div>
<a href="/" class="header-title"></a>
<div class="header-navigation">
:menu_site
</div>
</div>
<div class="content">

2
templates/home.html Normal file
View File

@ -0,0 +1,2 @@
<p>this site has nothing in particular to do with splatoon lol, it's just a ytmnd clone</p>
<p>for the best experience, enable autoplay for this domain</p>

View File

@ -0,0 +1 @@
<br/><a href=":info_redirect">Click here if nothing happens.</a>

View File

@ -0,0 +1,6 @@
<fieldset class="information">
<legend class="information-title">:info_title</legend>
<div class="information-content">
:info_content
</div>
</fieldset>

View File

@ -0,0 +1 @@
<a href=":link" class="header-navigation-item">:text</a>

View File

@ -0,0 +1 @@
<a href=":link" class="header-usermenu-item">:text</a>

View File

@ -0,0 +1,3 @@
<h1 class="page-title">@:profile_username</h1>
<h2 class="page-subtitle">Zones</h2>
:profile_zones

View File

@ -0,0 +1,2 @@
<h1 class="page-title">User not found!</h1>
<p class="page-paragraph">No user with this username exists.</p>

View File

@ -0,0 +1,16 @@
<div class="zones-list-item">
<a class="zones-list-item-screenshot" href=":zone_url" target="_blank">
<img src=":zone_screenshot" alt=":zone_name" class="zones-list-item-screenshot-image"/>
</a>
<div class="zones-list-item-info">
<div class="zones-list-item-info-name">
<a href=":zone_url" target="_blank">:zone_name</a>
</div>
<div class="zones-list-item-info-title">
<a href=":zone_url" target="_blank">:zone_title</a>
</div>
<div class="zones-list-item-info-misc">
:zone_views views
</div>
</div>
</div>

View File

@ -0,0 +1,3 @@
<div class="zones-list">
:zone_items
</div>

View File

@ -0,0 +1 @@
<p class="page-paragraph">This user has no zones yet.</p>

View File

@ -0,0 +1 @@
<h1 class="page-title">Settings</h1>

View File

@ -0,0 +1,4 @@
<form method="post" action="/settings/invites" class="invites-create">
<input type="hidden" name="invite_token" value=":create_token"/>
<input type="submit" value="Create Invite" class="invites-create-button"/>
</form>

View File

@ -0,0 +1,14 @@
<div class="invites-list-item">
<div class="invites-list-item-created">
:invite_created
</div>
<div class="invites-list-item-used">
:invite_used
</div>
<div class="invites-list-item-user">
:invite_user
</div>
<div class="invites-list-item-token">
<code onclick="navigator.clipboard.writeText('https://ytkns.com/auth/register?inv=:invite_token');" title="Click to copy registration link with invite">:invite_token</code>
</div>
</div>

View File

@ -0,0 +1,22 @@
<h1 class="page-title">Invites</h1>
<div class="invites">
:invite_error
:invite_create
<div class="invites-list">
<div class="invites-list-item">
<div class="invites-list-item-created">
Created on
</div>
<div class="invites-list-item-used">
Used on
</div>
<div class="invites-list-item-user">
Used by
</div>
<div class="invites-list-item-token">
Invitation
</div>
</div>
:invite_list
</div>
</div>

View File

@ -0,0 +1,20 @@
<h1 class="page-title">Create a Zone</h1>
<form action=":create_action" method="post" class="zone-creation-form">
:create_error
<input type="hidden" name="zone_token" value=":create_token"/>
<label class="zone-creation-input">
<div class="zone-creation-label">Subdomain:</div>
<div class="zone-creation-wrap">
<input type="text" maxlength="50" name="zone_subdomain" value=":create_subdomain" class="zone-creation-value"/>.:create_domain
</div>
</label>
<label class="zone-creation-input">
<div class="zone-creation-label">Title:</div>
<div class="zone-creation-wrap">
<input type="text" maxlength="255" name="zone_title" value=":create_title" class="zone-creation-value"/>
</div>
</label>
<div class="zone-creation-actions">
<input type="submit" value="Create This Zone" class="zone-creation-action"/>
</div>
</form>

View File

@ -0,0 +1,8 @@
<h1 class="page-title">Deleting Zone - :delete_zone_name</h1>
<form method="post" action="/zones/:delete_zone_id/delete" class="zone-creation-form">
<input type="hidden" name="zone_token" value=":delete_zone_token"/>
<div class="zone-creation-actions">
<input type="submit" value="Yes, delete this Zone" class="zone-creation-action"/>
<a href="/zones?f=my" class="zone-creation-action">No, bring me back to the Zone list</a>
</div>
</form>

25
templates/zones/edit.html Normal file
View File

@ -0,0 +1,25 @@
<noscript>
<div class="noscript error">
The editor requires Javascript. Please enable it or use a compatible browser.
</div>
</noscript>
<div id="editor">
<div class="loading-editor">
<div class="loading-editor-image"></div>
<div class="loading-editor-text">
Loading editor...
</div>
</div>
</div>
<script type="text/javascript">
window.onload = function() {
document.getElementById('ytkns-footer').appendChild(document.createTextNode('| JS: :edit_js_ver | CSS: :edit_css_ver'));
ytknsEditorMain(
document.getElementById('editor'),
:edit_id,
':edit_token',
':upload_token'
);
};
</script>

16
templates/zones/item.html Normal file
View File

@ -0,0 +1,16 @@
<div class="zones-list-item">
<a class="zones-list-item-screenshot" href=":zone_url" target="_blank">
<img src=":zone_screenshot" alt=":zone_name" class="zones-list-item-screenshot-image"/>
</a>
<div class="zones-list-item-info">
<div class="zones-list-item-info-name">
<a href=":zone_url" target="_blank">:zone_name</a>
</div>
<div class="zones-list-item-info-title">
<a href=":zone_url" target="_blank">:zone_title</a>
</div>
<div class="zones-list-item-info-misc">
:zone_views views - <a href=":zone_user_url">@:zone_user_name</a>
</div>
</div>
</div>

View File

@ -0,0 +1,7 @@
<h1 class="page-title">:zone_list_title</h1>
<div class="zones-sorts">
:zone_sortings
</div>
<div class="zones-list">
:zone_list
</div>

View File

@ -0,0 +1,33 @@
<!--div class="my-zones-list-item" id="z:zone_id">
<div class="my-zones-list-item-info">
<div class="my-zones-list-item-info-name">
<a href=":zone_view_url" target="_blank">:zone_name</a>
</div>
<div class="my-zones-list-item-info-title">
:zone_views views - :zone_title
</div>
</div>
<div class="my-zones-list-item-actions">
<a href=":zone_view_url" title="View Zone" class="my-zones-list-item-actions-action my-zones-list-item-actions-action--view" target="_blank"></a>
<a href=":zone_edit_url" title="Edit Zone" class="my-zones-list-item-actions-action my-zones-list-item-actions-action--edit"></a>
<a href=":zone_delete_url" title="Delete Zone" class="my-zones-list-item-actions-action my-zones-list-item-actions-action--delete"></a>
</div>
</div-->
<div class="zones-list-item">
<a class="zones-list-item-screenshot" href=":zone_url" target="_blank">
<img src=":zone_screenshot" alt=":zone_name" class="zones-list-item-screenshot-image"/>
</a>
<div class="zones-list-item-info">
<div class="zones-list-item-info-name">
<a href=":zone_url" target="_blank">:zone_name</a>
</div>
<div class="zones-list-item-info-title">
<a href=":zone_url" target="_blank">:zone_views views - :zone_title</a>
</div>
<div class="zones-list-item-info-actions">
<a href=":zone_url" title="View Zone" class="zones-list-item-info-actions-action zones-list-item-info-actions-action--view" target="_blank"></a>
<a href=":zone_edit_url" title="Edit Zone" class="zones-list-item-info-actions-action zones-list-item-info-actions-action--edit"></a>
<a href=":zone_delete_url" title="Delete Zone" class="zones-list-item-info-actions-action zones-list-item-info-actions-action--delete"></a>
</div>
</div>
</div>

View File

@ -0,0 +1,4 @@
<h1 class="page-title">My Zones</h1>
<div class="my-zones-list">
:zone_list
</div>

View File

@ -0,0 +1,9 @@
<h1 class="page-title">My Zones</h1>
<div class="zone-creation-form">
<div class="zone-creation-paragraph">
You have no zones yet.
</div>
<div class="zone-creation-actions">
<a href="/zones/create" class="zone-creation-action">Create a Zone!</a>
</div>
</div>

View File

@ -0,0 +1,4 @@
<div class="nozone">
<h1>This zone doesn't exist!</h1>
<p>Would you like to <a href=":zone_create_url">create it</a>?</p>
</div>