commit 2f71fa5e62f8e0f76455533060f6d3429f67bfef Author: flashwave Date: Wed Jun 10 16:03:13 2020 +0000 Initial import. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..176a458 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b35a856 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/.debug +/public/ss +/uploads +/config.php diff --git a/cron.php b/cron.php new file mode 100644 index 0000000..2a398d3 --- /dev/null +++ b/cron.php @@ -0,0 +1,46 @@ +getId() !== $task->getZoneId()) + $zoneInfo = $task->getZone(); + + switch($task->getName()) { + case 'screenshot': + $zoneInfo->takeScreenshot(); + break; + } + + $task->delete(); +} + +sem_release($semaphore); diff --git a/public/assets/accept.png b/public/assets/accept.png new file mode 100644 index 0000000..89c8129 Binary files /dev/null and b/public/assets/accept.png differ diff --git a/public/assets/add.png b/public/assets/add.png new file mode 100644 index 0000000..6332fef Binary files /dev/null and b/public/assets/add.png differ diff --git a/public/assets/arrow_down.png b/public/assets/arrow_down.png new file mode 100644 index 0000000..2c4e279 Binary files /dev/null and b/public/assets/arrow_down.png differ diff --git a/public/assets/arrow_refresh.png b/public/assets/arrow_refresh.png new file mode 100644 index 0000000..0de2656 Binary files /dev/null and b/public/assets/arrow_refresh.png differ diff --git a/public/assets/arrow_rotate_clockwise.png b/public/assets/arrow_rotate_clockwise.png new file mode 100644 index 0000000..aa65210 Binary files /dev/null and b/public/assets/arrow_rotate_clockwise.png differ diff --git a/public/assets/arrow_up.png b/public/assets/arrow_up.png new file mode 100644 index 0000000..1ebb193 Binary files /dev/null and b/public/assets/arrow_up.png differ diff --git a/public/assets/bin_closed.png b/public/assets/bin_closed.png new file mode 100644 index 0000000..afe22ba Binary files /dev/null and b/public/assets/bin_closed.png differ diff --git a/public/assets/bomb.png b/public/assets/bomb.png new file mode 100644 index 0000000..1be3797 Binary files /dev/null and b/public/assets/bomb.png differ diff --git a/public/assets/cancel.png b/public/assets/cancel.png new file mode 100644 index 0000000..c149c2b Binary files /dev/null and b/public/assets/cancel.png differ diff --git a/public/assets/delete.png b/public/assets/delete.png new file mode 100644 index 0000000..08f2493 Binary files /dev/null and b/public/assets/delete.png differ diff --git a/public/assets/drive_add.png b/public/assets/drive_add.png new file mode 100644 index 0000000..29a35d5 Binary files /dev/null and b/public/assets/drive_add.png differ diff --git a/public/assets/editor.css b/public/assets/editor.css new file mode 100644 index 0000000..f7f3083 --- /dev/null +++ b/public/assets/editor.css @@ -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; +} diff --git a/public/assets/editor.js b/public/assets/editor.js new file mode 100644 index 0000000..2a9c269 --- /dev/null +++ b/public/assets/editor.js @@ -0,0 +1,1081 @@ +function ytknsRequestJson(url, callback, method, data, headers, handleErr, upload) { + if(!callback) + callback = function(){}; + + var xhr = new XMLHttpRequest; + + if(xhr.upload && upload) { + xhr.upload.onloadstart = function(ev) { upload('loadstart', ev); }; + xhr.upload.onprogress = function(ev) { upload('progress', ev) }; + xhr.upload.onload = function(ev) { upload('load', ev); }; + } + + xhr.onreadystatechange = function() { + if(xhr.readyState !== 4) + return; + + var json = JSON.parse(xhr.responseText); + + if(json.err && !handleErr) { + alert(json.err); + callback([]); + } else + callback(json); + }; + xhr.open(method || 'GET', url); + + if(headers && headers.length > 0) + for(var i = 0; i < headers.length; i++) + xhr.setRequestHeader(headers[i].name, headers[i].value); + + xhr.send(data || null); +} + +function ytknsEditorLoadEffects(callback) { + ytknsRequestJson('/zones/_effects', callback); +} + +function ytknsLoadZoneInfo(zoneId, callback) { + ytknsRequestJson('/zones/' + parseInt(zoneId).toString() + '.json', callback); +} + +function ytknsLoadUploadInfo(uploadId, callback) { + ytknsRequestJson('/uploads/' + uploadId.toString() + '.json', callback); +} + +function ytknsCreateUrlString(set) { + var parts = []; + + for(var i = 0; i < set.length; i++) + parts.push(encodeURIComponent(set[i].name) + '=' + encodeURIComponent(set[i].value)); + + return parts.join('&'); +} +function ytknsCreateUrlStringPart(name, value) { + return { 'name': name.toString(), 'value': ( + (typeof value).toLowerCase() === 'object' + ? JSON.stringify(value) + : value.toString() + ) }; +} + +function ytknsZoneInfoToUrlString(zoneInfo) { + return ytknsCreateUrlString(ytknsZoneInfoSerialise(zoneInfo, ytknsCreateUrlStringPart)); +} +function ytknsZoneInfoSerialise(zoneInfo, callback) { + var set = []; + + if(zoneInfo) { + set.push(callback('zone_token', ytknsEditorToken || '')); + set.push(callback('zone_id', zoneInfo.id)); + set.push(callback('zone_name', zoneInfo.name)); + set.push(callback('zone_title', zoneInfo.title)); + + if(zoneInfo.effects && zoneInfo.effects.length > 0) { + for(var i = 0; i < zoneInfo.effects.length; i++) { + var format = 'zone_effect[%1][%2]'.replace('%1', zoneInfo.effects[i].type), + keys = Object.keys(zoneInfo.effects[i].values); + + if(keys.length < 1) { + set.push(callback(format.replace('%2', '_'), '_')); + } else + for(var j = 0; j < keys.length; j++) { + var key = format.replace('%2', keys[j]); + set.push(callback(key, zoneInfo.effects[i].values[keys[j]] || '')); + } + } + } + } + + return set; +} + +function ytknsSaveZoneInfo(zoneInfo, callback) { + if(!zoneInfo || !zoneInfo.id) + return; + + ytknsRequestJson( + '/zones/' + parseInt(zoneInfo.id).toString() + '.json', + callback, + 'POST', + ytknsZoneInfoToUrlString(zoneInfo), + [{ name: 'Content-Type', value: 'application/x-www-form-urlencoded' }] + ); +} + +function ytknsEditorPreview(zoneInfo) { + var formElement = document.createElement('form'); + formElement.action = '/zones/_preview'; + formElement.method = 'post'; + formElement.target = '_blank'; + formElement.style.display = 'none'; + + var elements = ytknsZoneInfoSerialise(zoneInfo, function(name, value) { + var inputElement = document.createElement('input'); + inputElement.name = name; + inputElement.type = 'hidden'; + inputElement.value = ( + (typeof value).toLowerCase() === 'object' + ? JSON.stringify(value) + : value.toString() + ); + formElement.appendChild(inputElement); + }); + + document.body.appendChild(formElement); + formElement.submit(); + formElement.parentNode.removeChild(formElement); +} + +function ytknsEditorCreateUpload(file, callback, status) { + var formData = new FormData; + formData.append('upload_token', ytknsEditorUploadToken || ''); + formData.append('upload_file', file); + + ytknsRequestJson( + '/uploads', + callback, + 'POST', + formData, + null, + true, + status + ); +} + +var ytknsZoneInfo = null, + ytknsEditorToken = '', + ytknsEditorUploadToken = '', + ytknsEditorElemSidebar = null, + ytknsEditorElemSidebarButtons = null, + ytknsEditorElemSidebarEffects = null, + ytknsEditorElemMain = null, + ytknsEditorElemMainTitle = null, + ytknsEditorElemMainContainer = null, + ytknsEditorEffects = [], + ytknsEditorIgnoreHashChange = false, + ytknsEditorCleanExit = true; + +function ytknsEditorChangeHash(hash) { + ytknsEditorIgnoreHashChange = true; + location.hash = hash; +} + +function ytknsEditorBeforeUnload(ev) { + if(!ytknsEditorCleanExit) { + ev.preventDefault(); + ev.returnValue = ''; + } +} + +function ytknsGetEffectInfoByType(type) { + for(var i = 0; i < ytknsEditorEffects.length; i++) + if(ytknsEditorEffects[i].type === type) + return ytknsEditorEffects[i]; + return null; +} + +function ytknsGetEffectValueByType(type) { + if(ytknsZoneInfo === null || ytknsZoneInfo.effects === null || ytknsZoneInfo.effects.length < 1) + return null; + + for(var i = 0; i < ytknsZoneInfo.effects.length; i++) + if(ytknsZoneInfo.effects[i].type === type) + return ytknsZoneInfo.effects[i]; + + return null; +} +function ytknsRemoveEffectValueByType(type) { + var info = ytknsGetEffectValueByType(type); + + if(!info) { + alert('Tried to remove an effect that doesn\'t exist.'); + return; + } + + var index = ytknsZoneInfo.effects.indexOf(info); + + if(index >= 0) + ytknsZoneInfo.effects.splice(index, 1); + + ytknsEditorUpdateSidebarEffects(); +} + +function ytknsEditorFilePickerDoUpload(files, callback, status) { + if(files.length < 1) { + alert('No file selected.'); + return; + } + + var file = files[0]; + + ytknsEditorCreateUpload(file, function(resp) { + if(resp.file) { + ytknsLoadUploadInfo(resp.file, function(uploadInfo) { + callback(uploadInfo); + }); + } else if(resp.err) { + alert(resp.err); + if(status) + status(-1, -1); + } + }, function(type, ev) { + if(status) + status(ev.loaded, ev.total); + }); +} + +function ytknsEditorShowFilePicker(mimes, title, callback) { + var container = document.createElement('div'); + container.classList.add('ye-applet-uploads'); + + var progressBarContainer = document.createElement('div'); + progressBarContainer.classList.add('ye-applet-uploads-progress'); + progressBarContainer.classList.add('ye-applet-uploads-hidden'); + container.appendChild(progressBarContainer); + + var progressBar = document.createElement('progress'); + progressBar.min = 0; + progressBar.max = 100; + progressBar.value = 0; + progressBar.classList.add('ye-applet-uploads-progress-bar'); + progressBarContainer.appendChild(progressBar); + + var dropZone = document.createElement('div'), + dropZoneInner = document.createElement('div'); + dropZone.classList.add('ye-applet-uploads-dropzone'); + dropZoneInner.classList.add('ye-applet-uploads-dropzone-inner'); + dropZoneInner.textContent = 'Drop a file or click here!'; + dropZone.appendChild(dropZoneInner); + container.appendChild(dropZone); + + var cancel = document.createElement('input'); + cancel.classList.add('ye-applet-uploads-cancel'); + cancel.type = 'button'; + cancel.value = 'Cancel'; + cancel.onclick = function() { callback(null); }; + container.appendChild(cancel); + + var doUpload = function(files) { + dropZone.classList.add('ye-applet-uploads-hidden'); + cancel.classList.add('ye-applet-uploads-hidden'); + progressBarContainer.classList.remove('ye-applet-uploads-hidden'); + + ytknsEditorFilePickerDoUpload(files, function(resp) { + dropZone.classList.remove('ye-applet-uploads-hidden'); + cancel.classList.remove('ye-applet-uploads-hidden'); + progressBarContainer.classList.add('ye-applet-uploads-hidden'); + callback(resp); + }, function(loaded, total) { + if(loaded < 0 && total < 0) { + dropZone.classList.remove('ye-applet-uploads-hidden'); + cancel.classList.remove('ye-applet-uploads-hidden'); + progressBarContainer.classList.add('ye-applet-uploads-hidden'); + return; + } + + progressBar.value = Math.ceil((loaded / total) * 100); + }); + }; + + dropZone.onclick = function() { + var selector = document.createElement('input'); + selector.type = 'file'; + selector.onchange = function() { doUpload(selector.files); }; + + if(mimes) + selector.accept = mimes.join(','); + + selector.click(); + }; + dropZone.ondragenter = function(ev) { + ev.preventDefault(); + ev.stopPropagation(); + dropZone.classList.add('ye-applet-uploads-dropzone--active'); + }; + dropZone.ondragleave = function(ev) { + ev.preventDefault(); + ev.stopPropagation(); + dropZone.classList.remove('ye-applet-uploads-dropzone--active'); + }; + dropZone.ondragover = function(ev) { + ev.preventDefault(); + ev.stopPropagation(); + dropZone.classList.add('ye-applet-uploads-dropzone--active'); + }; + dropZone.ondrop = function(ev) { + ev.preventDefault(); + ev.stopPropagation(); + dropZone.classList.remove('ye-applet-uploads-dropzone--active'); + doUpload(ev.dataTransfer.files); + }; + + ytknsEditorMainSetContainer(container, title || 'Upload File'); +} + +function ytknsLoadEffectEditor(effectInfo, effectValue) { + ytknsEditorChangeHash(effectInfo.type); + + var editor = document.createElement('div'); + editor.classList.add('ye-applet-editor'); + + if(effectInfo.props.length < 1) { + var empty = document.createElement('div'); + editor.classList.add('ye-applet-editor--fill'); + empty.classList.add('ye-applet-editor-empty'); + empty.textContent = 'This effect has no properties.'; + editor.appendChild(empty); + } else { + var properties = document.createElement('div'); + properties.classList.add('ye-applet-editor-properties'); + editor.appendChild(properties); + + for(var i = 0; i < effectInfo.props.length; i++) { + ytknsAddEffectEditorProperty( + properties, + effectInfo, effectValue, effectInfo.props[i], + function() { ytknsLoadEffectEditor(effectInfo, effectValue); } // there's gotta be a better way + ); + } + } + + ytknsEditorMainSetContainer(editor, effectInfo.name); +} +function ytknsAddEffectEditorProperty(listContainer, effectInfo, effectValue, propInfo, redraw) { + var container = document.createElement('div'); + container.classList.add('ye-applet-editor-properties-property'); + listContainer.appendChild(container); + + var propTitle = document.createElement('div'); + propTitle.classList.add('ye-applet-editor-properties-property-title'); + propTitle.textContent = propInfo.title; + container.appendChild(propTitle); + + var propWrap = document.createElement('div'); + propWrap.classList.add('ye-applet-editor-properties-property-wrap'); + container.appendChild(propWrap); + + switch(propInfo.type.name) { + case 'upload': + var propUpload = document.createElement('div'); + propUpload.classList.add('ye-applet-editor-properties-property-upload'); + propWrap.appendChild(propUpload); + + var propUploadId = document.createElement('div'); + propUploadId.classList.add('ye-applet-editor-properties-property-upload-id'); + propUploadId.textContent = effectValue.values[propInfo.name] || '(none)'; + propUpload.appendChild(propUploadId); + + var propUploadSelect = document.createElement('div'); + propUploadSelect.classList.add('ye-applet-editor-properties-property-upload-select'); + propUploadSelect.title = 'Upload file'; + propUploadSelect.onclick = function() { + ytknsEditorShowFilePicker(propInfo.type.allowed || [], ('Selecting ' + propInfo.title + ' for ' + effectInfo.name), function(uploadInfo) { + if(uploadInfo) + effectValue.values[propInfo.name] = uploadInfo.id || null; + + redraw(); + }); + }; + propUpload.appendChild(propUploadSelect); + break; + + case 'bool': + var propBool = document.createElement('label'); + propBool.classList.add('ye-applet-editor-properties-property-bool'); + propWrap.appendChild(propBool); + + var propBoolToggle = document.createElement('input'); + propBoolToggle.classList.add('ye-applet-editor-properties-property-bool-toggle'); + propBoolToggle.type = 'checkbox'; + propBoolToggle.checked = (typeof effectValue.values[propInfo.name]).toLowerCase() === 'undefined' + ? (propInfo.default || false) + : effectValue.values[propInfo.name]; + propBoolToggle.onchange = function() { + effectValue.values[propInfo.name] = propBoolToggle.checked; + redraw(); + }; + propBool.appendChild(propBoolToggle); + break; + + case 'int': + case 'float': + var propInt = document.createElement('label'); + propInt.classList.add('ye-applet-editor-properties-property-int'); + propWrap.appendChild(propInt); + + var propIntInput = document.createElement('input'); + propIntInput.classList.add('ye-applet-editor-properties-property-int-input'); + propIntInput.type = 'number'; + + if(propInfo.min) + propIntInput.min = propInfo.min; + if(propInfo.max) + propIntInput.max = propInfo.max; + if(propInfo.type.name === 'float') + propIntInput.step = 0.01; + + propIntInput.value = effectValue.values[propInfo.name] || propInfo.default || 0; + propIntInput.onchange = function() { + effectValue.values[propInfo.name] = propInfo.type.name === 'int' ? parseInt(propIntInput.value) : parseFloat(propIntInput.value); + redraw(); + }; + propInt.appendChild(propIntInput); + break; + + case 'select': + var propSelect = document.createElement('label'); + propSelect.classList.add('ye-applet-editor-properties-property-select'); + propWrap.appendChild(propSelect); + + var propSelectInput = document.createElement('select'); + propSelectInput.classList.add('ye-applet-editor-properties-property-select-input'); + propSelectInput.onchange = function() { + effectValue.values[propInfo.name] = propSelectInput.value; + redraw(); + }; + propSelect.appendChild(propSelectInput); + + var propSelectOptionsKeys = Object.keys(propInfo.type.options), + propSelectOptionsValues = Object.values(propInfo.type.options); + + for(var i = 0; i < propSelectOptionsKeys.length; i++) { + var propSelectOption = document.createElement('option'); + propSelectOption.value = propSelectOptionsKeys[i]; + propSelectOption.textContent = propSelectOptionsValues[i]; + propSelectOption.selected = (effectValue.values[propInfo.name] || propInfo.default) === propSelectOptionsKeys[i]; + propSelectInput.appendChild(propSelectOption); + } + break; + + case 'colour': + var propColour = document.createElement('label'); + propColour.classList.add('ye-applet-editor-properties-property-colour'); + propWrap.appendChild(propColour); + + var propColourInput = document.createElement('input'); + propColourInput.type = 'color'; + propColourInput.value = '#' + (effectValue.values[propInfo.name] || 0).toString(16).padStart(6, '0'); + propColourInput.classList.add('ye-applet-editor-properties-property-colour-input'); + propColourInput.onchange = function() { + effectValue.values[propInfo.name] = parseInt(propColourInput.value.substring(1), 16); + redraw(); + }; + propColour.appendChild(propColourInput); + break; + + case 'string': + var propString = document.createElement('label'); + propString.classList.add('ye-applet-editor-properties-property-string'); + propWrap.appendChild(propString); + + var propStringInput = document.createElement('input'); + propStringInput.type = 'text'; + + if(propInfo.type.min) + propStringInput.minLength = propInfo.type.min; + if(propInfo.type.max) + propStringInput.maxLength = propInfo.type.max; + + propStringInput.value = effectValue.values[propInfo.name] || ''; + propStringInput.classList.add('ye-applet-editor-properties-property-string-input'); + propStringInput.onchange = function() { + effectValue.values[propInfo.name] = propStringInput.value; + redraw(); + }; + propString.appendChild(propStringInput); + break; + + case 'gradient': + if(!effectValue.values[propInfo.name]) + effectValue.values[propInfo.name] = {}; + + var propGrad = document.createElement('div'); + propGrad.classList.add('ye-applet-editor-properties-property-gradient'); + propWrap.appendChild(propGrad); + + var propGradPreview = document.createElement('div'); + propGradPreview.classList.add('ye-applet-editor-properties-property-gradient-preview'); + propGrad.appendChild(propGradPreview); + + var propGradDirection = document.createElement('div'); + propGradDirection.classList.add('ye-applet-editor-properties-property-gradient-direction'); + propGrad.appendChild(propGradDirection); + + var propGradDirectionCircle = document.createElement('div'); + propGradDirectionCircle.classList.add('ye-applet-editor-properties-property-gradient-direction-circle'); + propGradDirection.appendChild(propGradDirectionCircle); + + var propGradDirectionCircleValue = document.createElement('div'); + propGradDirectionCircleValue.classList.add('ye-applet-editor-properties-property-gradient-direction-circle-value'); + propGradDirectionCircle.appendChild(propGradDirectionCircleValue); + + var propGradDirectionCircleIndicator = document.createElement('div'); + propGradDirectionCircleIndicator.classList.add('ye-applet-editor-properties-property-gradient-direction-circle-indicator'); + propGradDirectionCircleValue.appendChild(propGradDirectionCircleIndicator); + + var propGradDirectionValue = document.createElement('input'); + propGradDirectionValue.classList.add('ye-applet-editor-properties-property-gradient-direction-input'); + propGradDirectionValue.type = 'number'; + propGradDirectionValue.min = 0; + propGradDirectionValue.max = 359; + propGradDirectionValue.value = 0; + propGradDirection.appendChild(propGradDirectionValue); + + var propGradRedrawPreview = function() { + propGradPreview.style.backgroundImage = ytknsEditorGradientCSS(effectValue.values[propInfo.name]); + }; + + var propGradDirectionSet = function(val) { + val = parseInt(val); + effectValue.values[propInfo.name].d = val; + if(propGradDirectionValue.value != val) + propGradDirectionValue.value = val; + propGradDirectionCircleValue.style.transform = 'rotate(' + val + 'deg)'; + propGradRedrawPreview(); + }; + + propGradDirectionSet(effectValue.values[propInfo.name].d || 0); + + propGradDirectionValue.onchange = function() { + propGradDirectionSet(propGradDirectionValue.value); + }; + + var propGradDirectionCircleMouseEvent = function(ev) { + if((ev.buttons & 1) < 1) + return; + + var rect = ev.target.getBoundingClientRect(), + y = ev.layerY - (rect.height / 2), + x = ev.layerX - (rect.width / 2), + deg = Math.atan2(x, y) * 180 / Math.PI, + val = 180 - parseInt(deg); + + propGradDirectionSet(val); + }; + + propGradDirectionCircle.onmousedown = propGradDirectionCircleMouseEvent; + propGradDirectionCircle.onmousemove = propGradDirectionCircleMouseEvent; + + var propGradPoints = document.createElement('div'); + propGradPoints.classList.add('ye-applet-editor-properties-property-gradient-points'); + propGrad.appendChild(propGradPoints); + + var propGradPointAdd = function(point) { + if(!point) + point = {}; + if((typeof point.c).toLowerCase() === 'undefined') + point.c = parseInt(0xFFFFFF * Math.random()); + if((typeof point.o).toLowerCase() === 'undefined') + point.o = parseInt(100 * Math.random()); + + var propGradPoint = document.createElement('div'); + propGradPoint.classList.add('ye-applet-editor-properties-property-gradient-points-point'); + propGradPoints.appendChild(propGradPoint); + + var propGradPointActions = document.createElement('div'); + propGradPointActions.classList.add('ye-applet-editor-properties-property-gradient-points-point-actions'); + propGradPoint.appendChild(propGradPointActions); + + var propGradPointActionsAdd = function(name, title, action) { + var propGradPointAction = document.createElement('div'); + propGradPointAction.classList.add('ye-applet-editor-properties-property-gradient-points-point-actions-action'); + propGradPointAction.classList.add('ye-applet-editor-properties-property-gradient-points-point-actions-action--' + name); + propGradPointAction.title = title; + propGradPointAction.onclick = function() { if(action) action(); }; + propGradPointActions.appendChild(propGradPointAction); + }; + + propGradPointActionsAdd('delete', 'Delete', function() { + var index = effectValue.values[propInfo.name].p.indexOf(point); + if(index < 0) + return; + effectValue.values[propInfo.name].p.splice(index, 1); + propGradPoints.removeChild(propGradPoint); + propGradRedrawPreview(); + }); + + var propGradPointColour = document.createElement('label'); + propGradPointColour.classList.add('ye-applet-editor-properties-property-gradient-points-point-colour'); + propGradPoint.appendChild(propGradPointColour); + + var propGradPointColourValue = document.createElement('input'); + propGradPointColourValue.type = 'color'; + propGradPointColourValue.value = '#' + point.c.toString(16).padStart(6, '0'); + propGradPointColourValue.classList.add('ye-applet-editor-properties-property-gradient-points-point-colour-value'); + propGradPointColourValue.onchange = function() { + point.c = parseInt(propGradPointColourValue.value.substring(1), 16); + propGradPointColour.style.backgroundColor = propGradPointColourValue.value; + propGradRedrawPreview(); + }; + propGradPointColour.appendChild(propGradPointColourValue); + propGradPointColour.style.backgroundColor = propGradPointColourValue.value; + + var propGradPointOffset = document.createElement('input'); + propGradPointOffset.type = 'range'; + propGradPointOffset.classList.add('ye-applet-editor-properties-property-gradient-points-point-offset-range'); + propGradPointOffset.value = 0; + propGradPointOffset.min = 0; + propGradPointOffset.max = 100; + propGradPoint.appendChild(propGradPointOffset); + + var propGradPointOffsetNum = document.createElement('input'); + propGradPointOffsetNum.type = 'number'; + propGradPointOffsetNum.classList.add('ye-applet-editor-properties-property-gradient-points-point-offset-numeric'); + propGradPointOffsetNum.value = 0; + propGradPointOffsetNum.min = 0; + propGradPointOffsetNum.max = 100; + propGradPoint.appendChild(propGradPointOffsetNum); + + var propGradPointOffsetIsSetting = false, + propGradPointOffsetSet = function(val) { + if(propGradPointOffsetIsSetting) + return; + propGradPointOffsetIsSetting = true; + + val = parseInt(val); + propGradPointOffset.value = propGradPointOffsetNum.value = point.o = val; + propGradRedrawPreview(); + + propGradPointOffsetIsSetting = false; + }; + + propGradPointOffsetSet(point.o); + propGradPointOffset.onchange = function() { + propGradPointOffsetSet(propGradPointOffset.value); + }; + propGradPointOffsetNum.onchange = function() { + propGradPointOffsetSet(propGradPointOffsetNum.value); + }; + }; + + var propGradPointAddButton = document.createElement('button'); + propGradPointAddButton.classList.add('ye-applet-editor-properties-property-gradient-points-add'); + propGradPointAddButton.onclick = function() { + var point = {}; + if(!effectValue.values[propInfo.name].p) + effectValue.values[propInfo.name].p = []; + effectValue.values[propInfo.name].p.push(point); + propGradPointAdd(point); + propGradRedrawPreview(); + }; + propGradDirection.appendChild(propGradPointAddButton); + + var propGradPointAddButtonIcon = document.createElement('div'); + propGradPointAddButtonIcon.classList.add('ye-applet-editor-properties-property-gradient-points-add-icon'); + propGradPointAddButton.appendChild(propGradPointAddButtonIcon); + + var propGradPointAddButtonText = document.createElement('div'); + propGradPointAddButtonText.classList.add('ye-applet-editor-properties-property-gradient-points-add-text'); + propGradPointAddButtonText.textContent = 'Add gradient point'; + propGradPointAddButton.appendChild(propGradPointAddButtonText); + + if(effectValue.values[propInfo.name].p) + for(var i = 0; i < effectValue.values[propInfo.name].p.length; i++) + propGradPointAdd(effectValue.values[propInfo.name].p[i]); + break; + + default: + var propNone = document.createElement('div'); + propNone.classList.add('ye-applet-editor-properties-property-none'); + propNone.textContent = 'There is no handler available for this property type.'; + propWrap.appendChild(propNone); + break; + } + + var propDefault = document.createElement('div'); + propDefault.classList.add('ye-applet-editor-properties-property-reset'); + propDefault.title = 'Reset'; + propDefault.onclick = function() { + effectValue.values[propInfo.name] = propInfo.default; + redraw(); + }; + propWrap.appendChild(propDefault); +} + +function ytknsEditorGradientCSS(obj) { + var str = 'linear-gradient(' + (obj.d || 0).toString() + 'deg, ', + points = []; + + if(obj.p) + for(var i = 0; i < obj.p.length; i++) + points.push('#' + (obj.p[i].c || 0).toString(16).padStart(6, '0') + ' ' + (obj.p[i].o || 0).toString() + '%'); + + return str + points.join(', ') + ')'; +} + +function ytknsEditorAddSidebarButton(className, title, callback) { + var button = document.createElement('div'); + button.classList.add('ye-sidebar-buttons-button'); + button.classList.add('ye-sidebar-buttons-button--' + className); + button.title = title; + button.onclick = callback; + ytknsEditorElemSidebarButtons.appendChild(button); +} +function ytknsEditorAddSidebarButtonSeparator() { + var separator = document.createElement('div'); + separator.classList.add('ye-sidebar-buttons-separator'); + ytknsEditorElemSidebarButtons.appendChild(separator); +} + +function ytknsEditorAddSidebarEffect(effectValue) { + var effectInfo = ytknsGetEffectInfoByType(effectValue.type); + + if(!effectInfo) { + alert('Attempted to add unregistered effect.'); + return; + } + + var effect = document.createElement('div'); + effect.classList.add('ye-sidebar-effects-effect'); + ytknsEditorElemSidebarEffects.appendChild(effect); + + var effectName = document.createElement('div'); + effectName.classList.add('ye-sidebar-effects-effect-name'); + effectName.textContent = effectInfo.name; + effect.appendChild(effectName); + + var effectActions = document.createElement('div'); + effectActions.classList.add('ye-sidebar-effects-effect-actions'); + effect.appendChild(effectActions); + + ytknsEditorAddSidebarEffectAction(effectActions, 'edit', 'Edit Effect', function() { + ytknsLoadEffectEditor(effectInfo, effectValue); + }); + ytknsEditorAddSidebarEffectAction(effectActions, 'delete', 'Delete Effect', function() { + var previous = ytknsEditorMainClone(); + ytknsEditorMainShowEffectDeleteConfirm(effectInfo, function(accept) { + if(accept) { + ytknsRemoveEffectValueByType(effectValue.type); + ytknsEditorMainShowWelcome(); + } else { + ytknsEditorMainRestore(previous); + } + }); + }); +} +function ytknsEditorMainShowEffectDeleteConfirm(effectInfo, callback) { + var confirmation = document.createElement('div'), + text = confirmation.appendChild(document.createElement('div')), + actions = confirmation.appendChild(document.createElement('div')); + confirmation.classList.add('ye-main-effect-delete-confirm'); + text.classList.add('ye-main-effect-delete-confirm-text'); + text.textContent = 'Are you sure you want to delete "' + effectInfo.name + '"?'; + actions.classList.add('ye-main-effect-delete-confirm-actions'); + + var accept = document.createElement('div'); + accept.classList.add('ye-main-effect-delete-confirm-actions-action'); + accept.classList.add('ye-main-effect-delete-confirm-actions-action--accept'); + accept.textContent = 'Yes'; + accept.onclick = function() { callback(true); }; + actions.appendChild(accept); + + var deny = document.createElement('div'); + deny.classList.add('ye-main-effect-delete-confirm-actions-action'); + deny.classList.add('ye-main-effect-delete-confirm-actions-action--deny'); + deny.textContent = 'No'; + deny.onclick = function() { callback(false); }; + actions.appendChild(deny); + + ytknsEditorMainSetContainer(confirmation, 'Delete effect'); +} +function ytknsEditorAddSidebarEffectAction(container, className, title, callback) { + var action = document.createElement('div'); + action.classList.add('ye-sidebar-effects-effect-actions-action'); + action.classList.add('ye-sidebar-effects-effect-actions-action--' + className); + action.title = title; + action.onclick = callback; + container.appendChild(action); +} +function ytknsEditorUpdateSidebarEffects() { + ytknsEditorElemSidebarEffects.innerHTML = ''; + + if(ytknsZoneInfo == null || ytknsZoneInfo.effects == null || ytknsZoneInfo.effects.length < 1) { + var nothing = document.createElement('div'); + nothing.classList.add('ye-sidebar-effects-empty'); + nothing.textContent = 'You have not added any effects yet.'; + ytknsEditorElemSidebarEffects.appendChild(nothing); + } else { + for(var i = 0; i < ytknsZoneInfo.effects.length; i++) + ytknsEditorAddSidebarEffect(ytknsZoneInfo.effects[i]); + } +} + +function ytknsShowDetailsEdit() { + ytknsEditorChangeHash('details'); + + var container = document.createElement('div'); + container.classList.add('ye-main-details'); + + var fieldsElem = document.createElement('div'); + fieldsElem.classList.add('ye-main-details-fields'); + container.appendChild(fieldsElem); + + var fields = [ + { name: 'Zone ID', value: ytknsZoneInfo.id }, + { name: 'Subdomain', value: ytknsZoneInfo.name, suffix: '.' + location.host }, + { name: 'Title', value: ytknsZoneInfo.title, max: 255, edit: function(val) { + ytknsZoneInfo.title = val; + } }, + ]; + + for(var i = 0; i < fields.length; i++) { + var field = fields[i], + fieldElem = document.createElement('div'); + fieldElem.classList.add('ye-main-details-fields-field'); + + var fieldName = document.createElement('div'); + fieldName.classList.add('ye-main-details-fields-field-name'); + fieldName.textContent = field.name + ':'; + fieldElem.appendChild(fieldName); + + var fieldWrap = document.createElement('div'); + fieldWrap.classList.add('ye-main-details-fields-field-wrap'); + fieldElem.appendChild(fieldWrap); + + var fieldInput = document.createElement('input'); + fieldInput.classList.add('ye-main-details-fields-field-input'); + fieldInput.type = 'text'; + fieldInput.value = field.value; + + if(field.max) + fieldInput.maxLength = field.max; + + if(field.edit) { + var onedit = field.edit; + fieldInput.onchange = function(ev) { + onedit(ev.target.value); + }; + } else { + fieldInput.readOnly = true; + fieldElem.classList.add('ye-main-details-fields-field--readonly'); + } + + fieldWrap.appendChild(fieldInput); + + if(field.suffix) + fieldWrap.appendChild(document.createTextNode(field.suffix)); + + fieldsElem.appendChild(fieldElem); + } + + ytknsEditorMainSetContainer(container, 'Zone information'); +} + +function ytknsEditorMainGetTitle() { + return ytknsEditorElemMainTitle.textContent; +} +function ytknsEditorMainSetTitle(title) { + ytknsEditorElemMainTitle.textContent = title; +} +function ytknsEditorMainGetContainer() { + return ytknsEditorElemMainContainer.firstChild; +} +function ytknsEditorMainSetContainer(child, title) { + if(title) + ytknsEditorMainSetTitle(title); + + ytknsEditorElemMainContainer.innerHTML = ''; + ytknsEditorElemMainContainer.appendChild(child); +} +function ytknsEditorMainClone() { + return { + 'title': ytknsEditorMainGetTitle(), + 'content': ytknsEditorMainGetContainer().cloneNode(true), + }; +} +function ytknsEditorMainRestore(clone) { + ytknsEditorMainSetContainer(clone.content, clone.title); +} + +function ytknsEditorMainShowWelcome() { + var welcome = document.createElement('div'); + welcome.classList.add('ye-main-welcome'); + + var welcomeH1 = document.createElement('h1'); + welcomeH1.classList.add('ye-main-welcome-h1'); + welcomeH1.textContent = 'Welcome!'; + welcome.appendChild(welcomeH1); + + var welcomeP = document.createElement('p'); + welcomeP.classList.add('ye-main-welcome-p'); + welcomeP.textContent = 'Select a tool in the sidebar to get started.'; + welcome.appendChild(welcomeP); + + ytknsEditorMainSetContainer(welcome, 'Welcome to the YTKNS Editor'); +} + +function ytknsEditorMainShowEffectList() { + ytknsEditorChangeHash('add'); + + var container = document.createElement('div'); + container.classList.add('ye-applet-effects'); + + var effects = document.createElement('div'); + effects.classList.add('ye-applet-effects-list'); + container.appendChild(effects); + + for(var i = 0; i < ytknsEditorEffects.length; i++) { + var effectInfo = ytknsEditorEffects[i], + effectValue = ytknsGetEffectValueByType(effectInfo.type), + effectElement = document.createElement('div'), + effectUsed = effectValue !== null; + effectElement.classList.add('ye-applet-effects-list-item'); + + if(effectUsed) + effectElement.classList.add('ye-applet-effects-list-item--used'); + + var effectName = document.createElement('div'); + effectName.classList.add('ye-applet-effects-list-item-name'); + effectName.textContent = effectInfo.name; + effectElement.appendChild(effectName); + + var effectActions = document.createElement('div'); + effectActions.classList.add('ye-applet-effects-list-item-actions'); + effectElement.appendChild(effectActions); + + if(!effectUsed) { + var effectActionAdd = document.createElement('div'); + effectActionAdd.classList.add('ye-applet-effects-list-item-actions-action'); + effectActionAdd.classList.add('ye-applet-effects-list-item-actions-action--add'); + effectActionAdd.title = 'Add Effect'; + (function() { // javascript is very cool and good + var effectInfoCopy = effectInfo; + effectActionAdd.onclick = function() { + var effectValueNew = ytknsEditorAddNewEffect(effectInfoCopy); + ytknsEditorUpdateSidebarEffects(); + ytknsLoadEffectEditor(effectInfoCopy, effectValueNew); + }; + })(); + effectActions.appendChild(effectActionAdd); + } + + effects.appendChild(effectElement); + } + + ytknsEditorMainSetContainer(container, 'Available effects'); +} + +function ytknsEditorAddNewEffect(effectInfo) { + var effectValue = { + type: effectInfo.type, + values: {}, + }; + + for(var i = 0; i < effectInfo.props.length; i++) { + var propInfo = effectInfo.props[i]; + effectValue.values[propInfo.name] = propInfo.default || ''; + } + + ytknsZoneInfo.effects.push(effectValue); + + return effectValue; +} + +function ytknsEditorConfirmReloadZoneInfo(zoneId) { + if(confirm('Are you sure you want to undo any changes you\'ve made?')) + ytknsEditorReloadZoneInfo(zoneId); +} +function ytknsEditorReloadZoneInfo(zoneId, onComplete) { + ytknsEditorChangeHash(''); + ytknsEditorMainShowWelcome(); + + ytknsEditorLoadEffects(function(effects) { + ytknsEditorEffects = effects; + + ytknsLoadZoneInfo(zoneId, function(zoneInfo) { + ytknsZoneInfo = zoneInfo; + ytknsEditorUpdateSidebarEffects(); + + if(onComplete) + onComplete(); + }); + }); +} + +function ytknsEditorSwitchString(str) { + switch(str) { + case '': + ytknsEditorMainShowWelcome(); + break; + + case 'add': + ytknsEditorMainShowEffectList(); + break; + + case 'details': + ytknsShowDetailsEdit(); + break; + + default: + var openEffectInfo = ytknsGetEffectInfoByType(str); + + if(openEffectInfo) { + var openEffectValue = ytknsGetEffectValueByType(str); + ytknsLoadEffectEditor(openEffectInfo, openEffectValue); + } + break; + } +} + +function ytknsEditorHashChange(ev) { + if(!ytknsEditorIgnoreHashChange) + ytknsEditorSwitchString(location.hash.substring(1)); + + ytknsEditorIgnoreHashChange = false; +} + +function ytknsEditorMain(container, zoneId, editorToken, uploadToken) { + if(navigator.userAgent.match(/mobile/gi) && !confirm("The editor is not designed to be used on phones whatsoever.\r\nHit OK to continue anyway or cancel to whereever you came from.")) { + history.go(-1); + return; + } + + window.onhashchange = ytknsEditorHashChange; + window.onbeforeunload = ytknsEditorBeforeUnload; + ytknsEditorToken = editorToken; + ytknsEditorUploadToken = uploadToken; + + container.innerHTML = ''; + container.classList.add('ye'); + + ytknsEditorElemSidebar = container.appendChild(document.createElement('div')); + ytknsEditorElemSidebar.classList.add('ye-sidebar'); + + ytknsEditorElemSidebarButtons = ytknsEditorElemSidebar.appendChild(document.createElement('div')); + ytknsEditorElemSidebarButtons.classList.add('ye-sidebar-buttons'); + + ytknsEditorAddSidebarButton('save', 'Save', function() { + ytknsSaveZoneInfo(ytknsZoneInfo, function(res) { + if(res.msg) + alert(res.msg); + }); + }); + ytknsEditorAddSidebarButton('cancel', 'Cancel', function() { location.assign('/zones?f=my'); }); + ytknsEditorAddSidebarButton('reset', 'Undo Changes', function() { ytknsEditorConfirmReloadZoneInfo(zoneId); }); + ytknsEditorAddSidebarButton('edit', 'Edit Details', function() { ytknsShowDetailsEdit(); }); + ytknsEditorAddSidebarButtonSeparator(); + ytknsEditorAddSidebarButton('preview', 'Show Preview', function() { ytknsEditorPreview(ytknsZoneInfo); }); + ytknsEditorAddSidebarButton('live', 'View Live', function() { window.open('//%1.%2'.replace('%2', location.host).replace('%1', ytknsZoneInfo.name)); }); + ytknsEditorAddSidebarButtonSeparator(); + ytknsEditorAddSidebarButton('add', 'Add Effect', function() { ytknsEditorMainShowEffectList(); }); + + ytknsEditorElemSidebarEffects = ytknsEditorElemSidebar.appendChild(document.createElement('div')); + ytknsEditorElemSidebarEffects.classList.add('ye-sidebar-effects'); + ytknsEditorUpdateSidebarEffects(); + + ytknsEditorElemMain = container.appendChild(document.createElement('div')); + ytknsEditorElemMain.classList.add('ye-main'); + + ytknsEditorElemMainTitle = ytknsEditorElemMain.appendChild(document.createElement('div')); + ytknsEditorElemMainTitle.classList.add('ye-main-title'); + + ytknsEditorElemMainContainer = ytknsEditorElemMain.appendChild(document.createElement('div')); + ytknsEditorElemMainContainer.classList.add('ye-main-container'); + + var goToEditor = location.hash.substring(1); + + ytknsEditorReloadZoneInfo(zoneId, function() { + ytknsEditorSwitchString(goToEditor); + }); +} diff --git a/public/assets/link.png b/public/assets/link.png new file mode 100644 index 0000000..25eacb7 Binary files /dev/null and b/public/assets/link.png differ diff --git a/public/assets/no-screenshot.jpg b/public/assets/no-screenshot.jpg new file mode 100644 index 0000000..6834d5f Binary files /dev/null and b/public/assets/no-screenshot.jpg differ diff --git a/public/assets/page_white.png b/public/assets/page_white.png new file mode 100644 index 0000000..8b8b1ca Binary files /dev/null and b/public/assets/page_white.png differ diff --git a/public/assets/page_white_edit.png b/public/assets/page_white_edit.png new file mode 100644 index 0000000..b93e776 Binary files /dev/null and b/public/assets/page_white_edit.png differ diff --git a/public/assets/page_white_link.png b/public/assets/page_white_link.png new file mode 100644 index 0000000..bf7bd1c Binary files /dev/null and b/public/assets/page_white_link.png differ diff --git a/public/assets/page_white_magnify.png b/public/assets/page_white_magnify.png new file mode 100644 index 0000000..f6b74cc Binary files /dev/null and b/public/assets/page_white_magnify.png differ diff --git a/public/assets/pencil.png b/public/assets/pencil.png new file mode 100644 index 0000000..0bfecd5 Binary files /dev/null and b/public/assets/pencil.png differ diff --git a/public/assets/shared.css b/public/assets/shared.css new file mode 100644 index 0000000..8460385 --- /dev/null +++ b/public/assets/shared.css @@ -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; +} diff --git a/public/assets/shared.js b/public/assets/shared.js new file mode 100644 index 0000000..8980ffa --- /dev/null +++ b/public/assets/shared.js @@ -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(); +} diff --git a/public/assets/spinner.gif b/public/assets/spinner.gif new file mode 100644 index 0000000..3288d10 Binary files /dev/null and b/public/assets/spinner.gif differ diff --git a/public/assets/style.css b/public/assets/style.css new file mode 100644 index 0000000..99d2802 --- /dev/null +++ b/public/assets/style.css @@ -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; } diff --git a/public/assets/ytkns.png b/public/assets/ytkns.png new file mode 100644 index 0000000..e17da49 Binary files /dev/null and b/public/assets/ytkns.png differ diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..374b34c --- /dev/null +++ b/public/index.php @@ -0,0 +1,1071 @@ +'; + } + + if(!empty($vars['styles']) && is_array($vars['styles'])) { + foreach($vars['styles'] as $style) + $vars['head'] .= sprintf('', $style); + } + + $vars['menu_site'] = Template::renderSet('menu-site-item', [ + ['text' => 'Home', 'link' => page_url('/')], + ['text' => 'Create a Zone', 'link' => page_url('/zones/create')], + ['text' => 'Zones', 'link' => page_url('/zones')], + ]); + + $userMenu = []; + + if(UserSession::hasInstance()) { + $userName = UserSession::instance()->getUser()->getUsername(); + + $userMenu = [ + ['text' => "@{$userName}", 'link' => page_url("/@{$userName}")], + ['text' => 'My Zones', 'link' => page_url('/zones?f=my')], + ['text' => 'Settings', 'link' => page_url('/settings')], + ]; + + if(Config::get('user.invite_only', Config::TYPE_BOOL)) + $userMenu[] = ['text' => 'Invites', 'link' => page_url('/settings/invites')]; + + $userMenu[] = ['text' => 'Log out', 'link' => page_url('/auth/logout', ['s' => UserSession::instance()->getSmallToken()])]; + } else { + $userMenu = [ + ['text' => 'Log in', 'link' => page_url('/auth/login')], + ['text' => 'Register', 'link' => page_url('/auth/register')], + ]; + } + + $vars['menu_user'] = Template::renderSet('menu-user-item', $userMenu); + + return Template::renderRaw('header', $vars); +} + +function html_footer(array $vars = []): string { + $vars['footer_took'] = number_format(microtime(true) - YTKNS_STARTUP, 5); + $vars['footer_year'] = date('Y'); + + $scripts = $vars['scripts'] ?? null; + $vars['scripts'] = ''; + + if(!empty($scripts) && is_array($scripts)) { + foreach($scripts as $script) + $vars['scripts'] .= sprintf('', $script); + } + + return Template::renderRaw('footer', $vars); +} + +function html_information(string $message, string $title = 'Information', ?string $redirect = null, int $redirectTimeout = 2): string { + $html = html_header([ + 'title' => $title . ' - YTKNS', + 'redirect' => $redirect, + 'redirect_timeout' => $redirectTimeout, + ]); + + if(!empty($redirect)) + $message .= Template::renderRaw('information-redirect', [ + 'info_redirect' => $redirect, + ]); + + $html .= Template::renderRaw('information', [ + 'info_title' => $title, + 'info_content' => $message, + ]); + + $html .= html_footer(); + + return $html; +} + +function html_pagination(int $pages, int $current, string $urlFormat): string { + $html = ''; +} + +if(!empty($_COOKIE['ytkns_login']) && is_string($_COOKIE['ytkns_login'])) { + try { + $session = UserSession::byToken($_COOKIE['ytkns_login']); + $session->update(); + $session->setInstance(); + + if($session->getBump()) + setcookie('ytkns_login', $session->getToken(), $session->getExpires(), '/', '.' . Config::get('domain.main'), false, true); + + unset($session); + } catch(UserSessionNotFoundException $ex) {} +} + +$zoneName = strtolower(substr($_SERVER['HTTP_HOST'], 0, -strlen(Config::get('domain.main')) - 1)); + +if(!empty($zoneName)) { + $redirect = ZoneRedirect::find($zoneName); + + if($redirect !== null) { + $redirect->execute(); + return; + } + + try { + $zoneInfo = Zone::byName($zoneName); + } catch(ZoneNotFoundException $ex) { + http_response_code(404); + echo html_header(['title' => 'Zone not found!']); + Template::render('zones/none', [ + 'zone_create_url' => page_url('/zones/create', ['name' => $zoneName]), + ]); + echo html_footer(); + return; + } + + if(!empty($_GET['_refresh_screenshot'])) { + header('Location: /'); + $zoneInfo->takeScreenshot(); + return; + } + + ZoneView::increment($zoneInfo, $_SERVER['REMOTE_ADDR']); + + echo (string)$zoneInfo->getPageBuilder(true); + return; +} + +$reqMethod = $_SERVER['REQUEST_METHOD']; +$reqPath = '/' . trim(parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH), '/'); + +if($reqPath === '/') { + echo html_header(); + Template::render('home'); + echo html_footer(); + return; +} + +if(substr($reqPath, 0, 4) === '/ss/') { + header('X-Accel-Redirect: /assets/no-screenshot.jpg'); + return; +} + +if(preg_match('#^/@([A-Za-z0-9-_]+)$#', $reqPath, $matches)) { + try { + $profile = User::forProfile($matches[1]); + } catch(Exception $ex) { + http_response_code(404); + echo html_header(['title' => 'User not found - YTKNS']); + Template::render('profile/notfound'); + echo html_footer(); + return; + } + + $zones = Zone::byUser($profile, 'zone_views', false); + + if(count($zones) < 1) { + $profileZones = Template::renderRaw('profile/zone-none'); + } else { + $profileZones = []; + + foreach($zones as $zone) + $profileZones[] = [ + 'zone_id' => $zone->getId(), + 'zone_name' => $zone->getName(), + 'zone_title' => $zone->getTitle(), + 'zone_views' => number_format($zone->getViews()), + 'zone_url' => $zone->getUrl(), + 'zone_screenshot' => $zone->getScreenshotUrl(), + ]; + + $profileZones = Template::renderRaw('profile/zone-list', [ + 'zone_items' => Template::renderSet('profile/zone-item', $profileZones), + ]); + } + + echo html_header(['title' => $profile->username . ' @ YTKNS']); + Template::render('profile/index', [ + 'profile_username' => $profile->username, + 'profile_zones' => $profileZones, + ]); + echo html_footer(); + return; +} + +if($reqPath === '/zones') { + $zoneFilter = filter_input(INPUT_GET, 'f'); + + if($zoneFilter === 'my' && !UserSession::hasInstance()) { + echo html_information('You must be logged in to do this.'); + return; + } + + $zoneTake = 20; + $zonePage = max(filter_input(INPUT_GET, 'page', FILTER_SANITIZE_NUMBER_INT) - 1, 0); + $zoneOffset = $zonePage * $zoneTake; + $zoneSort = filter_input(INPUT_GET, 'sort'); + $zoneSortDesc = filter_input(INPUT_GET, 'asc', FILTER_SANITIZE_NUMBER_INT) < 1; + $zoneOrderings = [ + 'creation' => 'zone_created', + 'updated' => 'zone_updated', + 'views' => 'zone_views', + 'user' => 'user_id', + ]; + + if(!array_key_exists($zoneSort, $zoneOrderings)) { + switch($zoneFilter) { + case 'my': + $zoneSort = 'creation'; + break; + + default: + $zoneSort = 'views'; + $zoneSortDesc = false; + break; + } + } + + switch($zoneFilter) { + case 'my': + $zones = Zone::byUser(UserSession::instance()->getUser(), $zoneOrderings[$zoneSort], $zoneSortDesc, $zoneTake, $zoneOffset); + $zoneListTemplate = 'zones/my-item'; + $zoneListTitle = 'My Zones'; + break; + + default: + $zones = Zone::all($zoneOrderings[$zoneSort], $zoneSortDesc, $zoneTake, $zoneOffset); + $zoneListTemplate = 'zones/item'; + $zoneListTitle = 'Zones'; + $zoneFilter = ''; + break; + } + + $zoneCount = Zone::count(); + $zonePages = ceil($zoneCount / $zoneTake); + $zoneList = []; + + foreach($zones as $zone) + $zoneList[] = [ + 'zone_id' => $zone->getId(), + 'zone_name' => $zone->getName(), + 'zone_title' => $zone->getTitle(), + 'zone_views' => number_format($zone->getViews()), + 'zone_url' => $zone->getUrl(), + 'zone_edit_url' => page_url(sprintf('/zones/%d', $zone->getId())), + 'zone_delete_url' => page_url(sprintf('/zones/%d/delete', $zone->getId())), + 'zone_screenshot' => $zone->getScreenshotUrl(), + 'zone_user_url' => page_url('/@' . $zone->getUser()->getUsername()), + 'zone_user_name' => $zone->getUser()->getUsername(), + ]; + + $zoneSortings = ''; + + foreach(array_keys($zoneOrderings) as $order) { + $orderCurrent = $zoneSort === $order; + $zoneSortings .= sprintf( + '%3$s%5$s', + $order, $orderCurrent ? $zoneSortDesc : !$zoneSortDesc, ucfirst($order), + $orderCurrent ? ' zones-sorts-current' : '', + $orderCurrent ? sprintf( + ' %s', + $zoneSortDesc ? 'down' : 'up', + $zoneSortDesc ? 'Descending' : 'Ascending' + ) : '', $zoneFilter + ); + } + + echo html_header(['title' => $zoneListTitle . ' - YTKNS']); + Template::render('zones/list', [ + 'zone_list_title' => $zoneListTitle, + 'zone_list' => Template::renderSet($zoneListTemplate, $zoneList), + 'zone_sortings' => $zoneSortings, + ]); + echo html_pagination($zonePages, $zonePage + 1, sprintf('/zones?f=%s&sort=%s&asc=%d&page=%%s', $zoneFilter, $zoneSort, !$zoneSortDesc)); + echo html_footer(); + return; +} + +if($reqPath === '/zones/create') { + if(!UserSession::hasInstance()) { + http_response_code(403); + echo html_information('You must be logged in to do this.'); + return; + } + + $createToken = UserSession::instance()->getSmallToken(4); + + if($reqMethod === 'POST') { + $zoneToken = filter_input(INPUT_POST, 'zone_token'); + $zoneName = filter_input(INPUT_POST, 'zone_subdomain'); + $zoneTitle = filter_input(INPUT_POST, 'zone_title'); + + if($zoneToken !== $createToken) { + $createError = 'Invalid request.'; + } else { + if(empty($zoneName) || empty($zoneTitle)) { + $createError = 'Please fill in all fields.'; + } else { + if(!Zone::validName($zoneName)) { + $createError = 'Name contains invalid characters or is too long.'; + } elseif(ZoneRedirect::exists($zoneName) || Zone::exists($zoneName)) { + $createError = 'A Zone with this name already exists.'; + } elseif(!ctype_alpha($zoneName) + || mb_strlen($zoneTitle) > 255) { + $createError = 'Invalid data.'; + } else { + $createZone = Zone::create(UserSession::instance()->getUser(), $zoneName, $zoneTitle); + $createZone->addEffect(new \YTKNS\Effects\NewlyCreatedPageEffect); + + echo html_information('Zone created!', 'Information - YTKNS', "/zones/{$createZone->getId()}"); + return; + } + } + } + } elseif($reqMethod === 'GET') { + $zoneName = filter_input(INPUT_GET, 'name'); + } + + if(isset($createError)) { + $createError = Template::renderRaw('error', [ + 'error_text' => $createError, + ]); + } + + echo html_header(['title' => 'Create a Zone - YTKNS']); + + Template::render('zones/create', [ + 'create_action' => page_url('/zones/create'), + 'create_domain' => Config::get('domain.main'), + 'create_token' => $createToken, + 'create_error' => $createError ?? '', + 'create_subdomain' => $zoneName ?? '', + 'create_title' => $zoneTitle ?? '', + ]); + + echo html_footer(); + return; +} + +if($reqPath === '/zones/_effects') { + header('Content-Type: application/json; charset=utf-8'); + + if(!UserSession::hasInstance()) { + http_response_code(403); + echo json_encode([ + 'err' => 'You must be logged in.', + ]); + return; + } + + $effects = []; + + foreach(SITE_EFFECTS as $effectClass) { + $instance = new $effectClass; + $effects[] = [ + 'type' => trim(substr($effectClass, 14, -6), '\\'), + 'name' => $instance->getEffectName(), + 'props' => $instance->getEffectProperties(), + ]; + } + + echo json_encode($effects); + return; +} + +if($reqPath === '/zones/_preview') { + if(!UserSession::hasInstance()) { + http_response_code(403); + echo html_information('You must be logged in to do this.'); + return; + } + + if($reqMethod !== 'POST') { + http_response_code(405); + echo html_information('Must be a POST request.'); + return; + } + + $zoneToken = filter_input(INPUT_POST, 'zone_token'); + + if($zoneToken !== UserSession::instance()->getSmallToken(10)) { + http_response_code(403); + echo html_information('Invalid token.'); + return; + } + + $zoneTitle = filter_input(INPUT_POST, 'zone_title'); + $zoneEffects = filter_input(INPUT_POST, 'zone_effect', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY); + + try { + $zoneInfo = new Zone; + $zoneInfo->setTitle($zoneTitle); + $zoneInfo->setPassiveMode(); + + foreach($zoneEffects as $effectName => $effectValues) { + $effectInfo = ZoneEffect::effectClass($effectName); + $effectInfo->setEffectParams($effectValues); + $zoneInfo->addEffect($effectInfo); + } + } catch(ZoneInvalidTitleException $ex) { + http_response_code(400); + echo html_information('Invalid title.'); + return; + } catch(ZoneEffectClassNotFoundException $ex) { + http_response_code(400); + echo html_information(sprintf('Invalid effect name: %s.', $ex->getMessage())); + return; + } catch(PageEffectException $ex) { + http_response_code(400); + echo html_information(sprintf('%s: %s', $effectName ?? '', $ex->getMessage())); + return; + } catch(Exception $ex) { + http_response_code(500); + echo html_information(sprintf('Failed to generate preview.
%s: %s', get_class($ex), $ex->getMessage())); + return; + } + + echo (string)$zoneInfo->getPageBuilder(); + return; +} + +if(preg_match('#^/zones/([0-9]+)/delete$#', $reqPath, $matches)) { + if(!UserSession::hasInstance()) { + http_response_code(403); + echo html_information('You must be logged in to do this.'); + return; + } + + try { + $zoneInfo = Zone::byId($matches[1]); + } catch(ZoneNotFoundException $ex) { + http_response_code(404); + echo html_information('This zone doesn\'t exist.'); + return; + } + + if($zoneInfo->getUserId() !== UserSession::instance()->getUserId()) { + http_response_code(403); + echo html_information('You aren\'t allowed to touch this zone.'); + return; + } + + $deleteToken = UserSession::instance()->getSmallToken(20); + + if($reqMethod === 'POST') { + if(filter_input(INPUT_POST, 'zone_token') !== $deleteToken) { + http_response_code(403); + echo html_information('Invalid token.'); + return; + } + + header('Location: /zones?f=my'); + $zoneInfo->delete(); + return; + } + + echo html_header(['title' => sprintf('Deleting zone %s - YTKNS', $zoneInfo->getName())]); + Template::render('zones/delete', [ + 'delete_zone_id' => $zoneInfo->getId(), + 'delete_zone_name' => $zoneInfo->getName(), + 'delete_zone_token' => $deleteToken, + ]); + echo html_footer(); + return; +} + +if(preg_match('#^/zones/([0-9]+)$#', $reqPath, $matches)) { + if(!UserSession::hasInstance()) { + http_response_code(403); + echo html_information('You must be logged in to do this.'); + return; + } + + try { + $zoneInfo = Zone::byId($matches[1]); + } catch(ZoneNotFoundException $ex) { + http_response_code(404); + echo html_information('This zone doesn\'t exist.'); + return; + } + + if(UserSession::instance()->getUserId() !== $zoneInfo->getUserId() + && UserSession::instance()->getUserId() !== 1) { + http_response_code(403); + echo html_information('You aren\'t allowed to touch this zone.'); + return; + } + + $cssHash = hash_file('sha256', YTKNS_PUB . '/assets/editor.css'); + $jsHash = hash_file('sha256', YTKNS_PUB . '/assets/editor.js'); + + echo html_header([ + 'title' => 'Editing Zone - YTKNS', + 'styles' => [ + page_url('/assets/editor.css', ['v' => $cssHash]), + ], + ]); + Template::render('zones/edit', [ + 'edit_id' => $zoneInfo->getId(), + 'edit_token' => UserSession::instance()->getSmallToken(10), + 'edit_css_ver' => substr($cssHash, 0, 16), + 'edit_js_ver' => substr($jsHash, 0, 16), + 'upload_token' => UserSession::instance()->getSmallToken(6), + ]); + echo html_footer([ + 'scripts' => [ + page_url('/assets/editor.js', ['v' => $jsHash]), + ], + ]); + return; +} + +if(preg_match('#^/zones/([0-9]+).json$#', $reqPath, $matches)) { + header('Content-Type: application/json; charset=utf-8'); + + if(!UserSession::hasInstance()) { + http_response_code(403); + echo json_encode([ + 'err' => 'You must be logged in to do this.', + ]); + return; + } + + try { + $zoneInfo = Zone::byId($matches[1]); + } catch(ZoneNotFoundException $ex) { + http_response_code(404); + echo json_encode([ + 'err' => 'This zone doesn\'t exist', + ]); + return; + } + + if(UserSession::instance()->getUserId() !== $zoneInfo->getUserId() + && UserSession::instance()->getUserId() !== 1) { + http_response_code(403); + echo json_encode([ + 'err' => 'You aren\'t allowed to touch this zone.', + ]); + return; + } + + if($reqMethod === 'GET') { + echo $zoneInfo->toJson(true); + return; + } + + if($reqMethod !== 'POST') { + http_response_code(405); + echo json_encode([ + 'err' => 'Invalid request method.', + ]); + return; + } + + $zoneToken = filter_input(INPUT_POST, 'zone_token'); + + if($zoneToken !== UserSession::instance()->getSmallToken(10)) { + http_response_code(403); + echo json_encode([ + 'err' => 'Invalid token.', + ]); + return; + } + + $zoneId = filter_input(INPUT_POST, 'zone_id', FILTER_VALIDATE_INT); + + if($zoneId !== $zoneInfo->getId()) { + http_response_code(400); + echo json_encode([ + 'err' => 'Zone ID in POST data does not match the target zone ID.', + ]); + return; + } + + $zoneTitle = filter_input(INPUT_POST, 'zone_title'); + $zoneEffects = filter_input(INPUT_POST, 'zone_effect', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY); + + try { + $zoneInfo->setTitle($zoneTitle); + $zoneInfo->setPassiveMode(); + + if(is_array($zoneEffects)) { + foreach($zoneEffects as $effectName => $effectValues) { + $effectInfo = ZoneEffect::effectClass($effectName); + $effectInfo->setEffectParams($effectValues); + $zoneInfo->addEffect($effectInfo); + } + } + + $zoneInfo->update(['zone_title']); + + // Schedule a screenshot to be taken + $zoneInfo->queueTask('screenshot'); + } catch(ZoneInvalidTitleException $ex) { + http_response_code(400); + echo json_encode([ + 'err' => 'Invalid title.', + ]); + return; + } catch(ZoneEffectClassNotFoundException $ex) { + http_response_code(400); + echo json_encode([ + 'err' => sprintf('Invalid effect name: %s.', $ex->getMessage()), + ]); + return; + } catch(PageEffectException $ex) { + http_response_code(400); + echo json_encode([ + 'err' => sprintf('%s: %s', $effectName ?? '', $ex->getMessage()), + ]); + return; + } catch(Exception $ex) { + http_response_code(500); + echo json_encode([ + 'err' => sprintf("An unexpected error occurred.\r\n%s: %s", get_class($ex), $ex->getMessage()), + ]); + return; + } + + echo json_encode([ + 'msg' => 'Saved!', + ]); + return; +} + +if($reqPath === '/uploads') { + header('Content-Type: application/json; charset=utf-8'); + + if($reqMethod !== 'POST') { + http_response_code(405); + echo json_encode([ + 'err' => 'Unsupported request method.', + ]); + return; + } + + if(!UserSession::hasInstance()) { + http_response_code(403); + echo json_encode([ + 'err' => 'You must be logged in to upload files.', + ]); + return; + } + + if(filter_input(INPUT_POST, 'upload_token') !== UserSession::instance()->getSmallToken(6)) { + http_response_code(403); + echo json_encode([ + 'err' => 'Invalid upload token.', + ]); + return; + } + + if(empty($_FILES['upload_file']['tmp_name'])) { + http_response_code(400); + echo json_encode([ + 'err' => 'Missing file.', + ]); + return; + } + + $hash = hash_file('sha256', $_FILES['upload_file']['tmp_name']); + $existing = Upload::byHash($hash); + + if($existing !== null) { + if($existing->getDMCA() > 0) { + http_response_code(451); + echo json_encode([ + 'err' => 'This file has been removed in response to a DMCA takedown request. It may not be used.', + ]); + return; + } + + if($existing->getDeleted() > 0) { + http_response_code(404); + echo json_encode([ + 'err' => 'This file has been flagged for deletion, it cannot be reuploaded at this time.', + ]); + return; + } + + http_response_code(200); + echo json_encode([ + 'err' => 'File has been uploaded already.', + 'file' => $existing->getId(), + ]); + return; + } + + if($_FILES['upload_file']['size'] > 5242880) { + http_response_code(413); + echo json_encode([ + 'err' => 'Upload is too large.', + ]); + return; + } + + $contentType = mime_content_type($_FILES['upload_file']['tmp_name']); + + if(!in_array($contentType, ALLOWED_UPLOADS)) { + http_response_code(400); + echo json_encode([ + 'err' => sprintf('File type not allowed. (Must be %s)', implode(', ', ALLOWED_UPLOADS)), + ]); + return; + } + + try { + $upload = Upload::create(UserSession::instance()->getUser(), $_FILES['upload_file']['name'], $contentType, $hash); + } catch(UploadCreationFailedException $ex) { + http_response_code(500); + echo json_encode([ + 'err' => 'Failed to create upload record.', + ]); + return; + } + + if(!move_uploaded_file($_FILES['upload_file']['tmp_name'], $upload->getPath())) { + http_response_code(500); + echo json_encode([ + 'err' => 'Upload failed.', + ]); + return; + } + + http_response_code(201); + echo json_encode([ + 'file' => $upload->getId(), + ]); + return; +} + +if(preg_match('#^/uploads/([a-zA-Z0-9-_]{16})$#', $reqPath, $matches)) { + try { + $uploadInfo = Upload::byId($matches[1]); + } catch(UploadNotFoundException $ex) { + http_response_code(404); + return; + } + + if(!empty($_SERVER['HTTP_ORIGIN'])) { + $zoneDomain = sprintf(Config::get('domain.zone'), ''); + $originPart = substr($_SERVER['HTTP_ORIGIN'], strlen($_SERVER['HTTP_ORIGIN']) - strlen($zoneDomain)); + + if($originPart === $zoneDomain) { + header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']); + } + } + + if($_SERVER['REQUEST_METHOD'] !== 'HEAD' && $_SERVER['REQUEST_METHOD'] !== 'OPTIONS') + header('X-Accel-Redirect: /raw-uploads/' . $uploadInfo->getId()); + + header('Content-Type: ' . $uploadInfo->getType()); + header('Content-Disposition: inline; filename="' . $uploadInfo->getName() . '"'); + return; +} + +if(preg_match('#^/uploads/([a-zA-Z0-9-_]{16}).json$#', $reqPath, $matches)) { + header('Content-Type: application/json; charset=utf-8'); + + if(!UserSession::hasInstance()) { + http_response_code(403); + echo json_encode([ + 'err' => 'You must be logged in to do this.', + ]); + return; + } + + try { + $uploadInfo = Upload::byId($matches[1]); + } catch(UploadNotFoundException $ex) { + http_response_code(404); + echo json_encode([ + 'err' => 'Upload not found.', + ]); + return; + } + + echo $uploadInfo->toJson(true); + return; +} + +if($reqPath === '/settings') { + if(!UserSession::hasInstance()) { + http_response_code(404); + echo html_information('You must be logged in to access this page.'); + return; + } + + echo html_header(['title' => 'Settings - YTKNS']); + Template::render('settings/index'); + echo html_footer(); + return; +} + +if($reqPath === '/settings/invites') { + if(!UserSession::hasInstance()) { + http_response_code(404); + echo html_information('You must be logged in to access this page.'); + return; + } + + $currentUser = UserSession::instance()->getUser(); + $createdInvites = UserInvite::fromCreator($currentUser); + $createToken = $currentUser->getId() !== 1 /*&& count($createdInvites) >= 5*/ ? '' : UserSession::instance()->getSmallToken(11); + + if($reqMethod === 'POST') { + if(empty($createToken)) { + $inviteError = 'You\'ve reached the maximum amount of invites you can generate.'; + } elseif($createToken !== filter_input(INPUT_POST, 'invite_token')) { + $inviteError = 'Cannot create invite.'; + } else { + try { + $createdInvites[] = $createdInvite = UserInvite::create($currentUser); + $inviteError = $createdInvite->getToken(); + } catch(UserInviteCreationFailedException $ex) { + $inviteError = 'Invite creation failed.'; + } + } + } + + if(isset($inviteError)) + $inviteError = Template::renderRaw('error', [ + 'error_text' => $inviteError, + ]); + + if(!empty($createToken)) + $inviteCreate = Template::renderRaw('settings/invites-create', [ + 'create_token' => $createToken, + ]); + + $invitesItems = []; + foreach($createdInvites as $inviteItem) { + $invitesItems[] = [ + 'invite_created' => date('c', $inviteItem->getCreated()), + 'invite_used' => (($_used = $inviteItem->getUsed()) === null ? 'Unused' : date('c', $_used)), + 'invite_user' => (($_user = $inviteItem->getUser()) === null ? 'Unused' : sprintf('%1$s', $_user->getUsername())), + 'invite_token' => $inviteItem->getToken(), + ]; + } + + echo html_header(['title' => 'Invites - YTKNS']); + Template::render('settings/invites', [ + 'invite_error' => $inviteError ?? '', + 'invite_create' => $inviteCreate ?? '', + 'invite_list' => Template::renderSet('settings/invites-item', $invitesItems), + ]); + echo html_footer(); + return; +} + +if($reqPath === '/auth/login') { + if(UserSession::hasInstance()) { + http_response_code(404); + echo html_information('You are logged in already.'); + return; + } + + if($reqMethod === 'POST') { + $loginUsername = filter_input(INPUT_POST, 'username'); + $loginPassword = filter_input(INPUT_POST, 'password'); + $loginRemember = !empty(filter_input(INPUT_POST, 'remember')); + + if(empty($loginUsername) || empty($loginPassword)) { + $authError = 'Username or password missing.'; + } else { + try { + $loginUser = User::forLogin($loginUsername); + } catch(\Exception $ex) {} + + if(empty($loginUser) || empty($loginUser->password) || !password_verify($loginPassword, $loginUser->password)) { + $authError = 'Username or password was invalid.'; + } else { + $session = UserSession::create($loginUser, $loginRemember); + $session->setInstance(); + setcookie('ytkns_login', $session->getToken(), $session->getExpires(), '/', '.' . Config::get('domain.main'), false, true); + echo html_information('You are now logged in!', 'Welcome', '/'); + return; + } + } + } + + if(isset($authError)) + $authError = Template::renderRaw('error', [ + 'error_text' => $authError, + ]); + + $authFields = [ + [ + 'field_title' => 'Username or E-Mail Address', + 'field_type' => 'text', + 'field_name' => 'username', + 'field_value' => ($loginUsername ?? ''), + ], + [ + 'field_title' => 'Password', + 'field_type' => 'password', + 'field_name' => 'password', + 'field_value' => '', + ], + ]; + + echo html_header(['title' => 'Log in - YTKNS']); + Template::render('auth/login' . (isset($_GET['new']) ? '2' : ''), [ + 'auth_error' => $authError ?? '', + 'auth_fields' => Template::renderSet('auth/field', $authFields), + 'auth_remember' => ($loginRemember ?? false) ? ' checked' : '', + ]); + echo html_footer(); + return; +} + +if($reqPath === '/auth/register') { + if(UserSession::hasInstance()) { + http_response_code(404); + echo html_information('You are logged in already.'); + return; + } + + $inviteOnly = Config::get('user.invite_only', Config::TYPE_BOOL); + + if($reqMethod === 'POST') { + $registerUsername = filter_input(INPUT_POST, 'username'); + $registerPassword = filter_input(INPUT_POST, 'password'); + $registerEMail = filter_input(INPUT_POST, 'email'); + $registerInvite = filter_input(INPUT_POST, 'invite'); + + if(empty($registerUsername) || empty($registerPassword) || empty($registerEMail) || ($inviteOnly && empty($registerInvite))) { + $authError = 'You must fill in all fields.'; + } else { + if($inviteOnly) { + try { + $userInvite = UserInvite::byToken($registerInvite); + + if($userInvite->isUsed()) { + $authError = 'Invalid invite token.'; + } + } catch(UserInviteNotFoundException $ex) { + $authError = 'Invalid invite token.'; + } + } + + if(!isset($authError)) { + if(!preg_match('#^([a-zA-Z0-9-_]{1,20})$#', $registerUsername)) { + } + + try { + $createdUser = User::create( + $registerUsername, + $registerPassword, + $registerEMail + ); + + if(isset($userInvite)) + $userInvite->markUsed($createdUser); + } catch(UserCreationInvalidNameException $ex) { + $authError = 'Your username contains invalid characters or is too short or long.
Must be between 1 and 20 characters and may only contains alphanumeric characters as well as - and _.'; + } catch(UserCreationInvalidPasswordException $ex) { + $authError = 'Your password must have at least 6 unique characters.'; + } catch(UserCreationInvalidMailException $ex) { + $authError = 'Your e-mail address isn\'t real.'; + } catch(UserCreationFailedException $ex) { + $authError = 'Failed to create user.'; + } + } + } + } elseif($reqMethod === 'GET') { + $registerInvite = filter_input(INPUT_GET, 'inv', FILTER_SANITIZE_STRING); + } + + $authFields = [ + [ + 'field_title' => 'Username', + 'field_type' => 'text', + 'field_name' => 'username', + 'field_value' => ($registerUsername ?? ''), + ], + [ + 'field_title' => 'Password', + 'field_type' => 'password', + 'field_name' => 'password', + 'field_value' => '', + ], + [ + 'field_title' => 'E-Mail Address', + 'field_type' => 'email', + 'field_name' => 'email', + 'field_value' => ($registerEMail ?? ''), + ], + ]; + + if($inviteOnly) + $authFields[] = [ + 'field_title' => 'Invitation', + 'field_type' => 'password', + 'field_name' => 'invite', + 'field_value' => ($registerInvite ?? ''), + ]; + + if(isset($authError)) + $authError = Template::renderRaw('error', [ + 'error_text' => $authError, + ]); + + echo html_header(['title' => 'Register - YTKNS']); + Template::render('auth/register', [ + 'auth_error' => $authError ?? '', + 'auth_fields' => Template::renderSet('auth/field', $authFields), + ]); + echo html_footer(); + return; +} + +if($reqPath === '/auth/logout') { + if(!UserSession::hasInstance()) { + http_response_code(404); + echo html_information('You are not logged in.'); + return; + } + + if(filter_input(INPUT_GET, 's') !== UserSession::instance()->getSmallToken()) { + http_response_code(403); + echo html_information('Log out verification failed, please try again.'); + return; + } + + UserSession::instance()->destroy(); + UserSession::unsetInstance(); + echo html_information('You have been logged out.', 'Log out', '/'); + return; +} + +http_response_code(404); +echo html_information('The requested page does not exist.', 'Page not found'); diff --git a/src/Colour.php b/src/Colour.php new file mode 100644 index 0000000..fa1bbee --- /dev/null +++ b/src/Colour.php @@ -0,0 +1,27 @@ +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); + } +} diff --git a/src/Config.php b/src/Config.php new file mode 100644 index 0000000..4cb0216 --- /dev/null +++ b/src/Config.php @@ -0,0 +1,56 @@ + 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); + } +} diff --git a/src/DB.php b/src/DB.php new file mode 100644 index 0000000..396fd20 --- /dev/null +++ b/src/DB.php @@ -0,0 +1,32 @@ + 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); + } +} diff --git a/src/Effects/BackgroundAudioEffect.php b/src/Effects/BackgroundAudioEffect.php new file mode 100644 index 0000000..090b463 --- /dev/null +++ b/src/Effects/BackgroundAudioEffect.php @@ -0,0 +1,137 @@ + '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); + } +} diff --git a/src/Effects/BackgroundImageEffect.php b/src/Effects/BackgroundImageEffect.php new file mode 100644 index 0000000..51b706e --- /dev/null +++ b/src/Effects/BackgroundImageEffect.php @@ -0,0 +1,401 @@ + '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); + } + } +} diff --git a/src/Effects/ForegroundImageEffect.php b/src/Effects/ForegroundImageEffect.php new file mode 100644 index 0000000..7789cc7 --- /dev/null +++ b/src/Effects/ForegroundImageEffect.php @@ -0,0 +1,153 @@ + '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); + } + } + } +} diff --git a/src/Effects/NewlyCreatedPageEffect.php b/src/Effects/NewlyCreatedPageEffect.php new file mode 100644 index 0000000..30067a7 --- /dev/null +++ b/src/Effects/NewlyCreatedPageEffect.php @@ -0,0 +1,45 @@ +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')]), + ])); + } +} diff --git a/src/Effects/ZoomTextEffect.php b/src/Effects/ZoomTextEffect.php new file mode 100644 index 0000000..bc123f8 --- /dev/null +++ b/src/Effects/ZoomTextEffect.php @@ -0,0 +1,57 @@ + '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)); + } +} diff --git a/src/Gradient.php b/src/Gradient.php new file mode 100644 index 0000000..32cf126 --- /dev/null +++ b/src/Gradient.php @@ -0,0 +1,87 @@ + $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; + } +} diff --git a/src/GradientPoint.php b/src/GradientPoint.php new file mode 100644 index 0000000..02ba1cb --- /dev/null +++ b/src/GradientPoint.php @@ -0,0 +1,65 @@ +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()); + } +} \ No newline at end of file diff --git a/src/HtmlTag.php b/src/HtmlTag.php new file mode 100644 index 0000000..44b40e3 --- /dev/null +++ b/src/HtmlTag.php @@ -0,0 +1,126 @@ +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', $this->getTagName(), $attrs, $children); + } +} diff --git a/src/HtmlText.php b/src/HtmlText.php new file mode 100644 index 0000000..fc8b79a --- /dev/null +++ b/src/HtmlText.php @@ -0,0 +1,14 @@ +text = $text; + } + + public function asHTML(): string { + return htmlspecialchars($this->text); + } +} diff --git a/src/HtmlTypeInterface.php b/src/HtmlTypeInterface.php new file mode 100644 index 0000000..7d5e696 --- /dev/null +++ b/src/HtmlTypeInterface.php @@ -0,0 +1,6 @@ +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 '' . $this->tagHtml->asHTML(); + } + + public function __toString(): string { + return $this->serialise(); + } +} diff --git a/src/PageEffectInterface.php b/src/PageEffectInterface.php new file mode 100644 index 0000000..b3c6709 --- /dev/null +++ b/src/PageEffectInterface.php @@ -0,0 +1,14 @@ + $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; + } +} diff --git a/src/Upload.php b/src/Upload.php new file mode 100644 index 0000000..f029c54 --- /dev/null +++ b/src/Upload.php @@ -0,0 +1,276 @@ +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(); + } + } +} diff --git a/src/User.php b/src/User.php new file mode 100644 index 0000000..5f2f9c7 --- /dev/null +++ b/src/User.php @@ -0,0 +1,123 @@ +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; + } + } +} diff --git a/src/UserInvite.php b/src/UserInvite.php new file mode 100644 index 0000000..437f447 --- /dev/null +++ b/src/UserInvite.php @@ -0,0 +1,122 @@ +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; + } + } +} diff --git a/src/UserSession.php b/src/UserSession.php new file mode 100644 index 0000000..0fb20e4 --- /dev/null +++ b/src/UserSession.php @@ -0,0 +1,149 @@ +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() + '); + } +} diff --git a/src/Zone.php b/src/Zone.php new file mode 100644 index 0000000..659b94a --- /dev/null +++ b/src/Zone.php @@ -0,0 +1,361 @@ +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); + } +} diff --git a/src/ZoneEffect.php b/src/ZoneEffect.php new file mode 100644 index 0000000..7f8828e --- /dev/null +++ b/src/ZoneEffect.php @@ -0,0 +1,102 @@ +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; + } +} diff --git a/src/ZoneRedirect.php b/src/ZoneRedirect.php new file mode 100644 index 0000000..9b106ed --- /dev/null +++ b/src/ZoneRedirect.php @@ -0,0 +1,46 @@ + 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)); + } +} diff --git a/src/ZoneTask.php b/src/ZoneTask.php new file mode 100644 index 0000000..af7878e --- /dev/null +++ b/src/ZoneTask.php @@ -0,0 +1,60 @@ +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(); + } +} diff --git a/src/ZoneView.php b/src/ZoneView.php new file mode 100644 index 0000000..b7e68ea --- /dev/null +++ b/src/ZoneView.php @@ -0,0 +1,44 @@ +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(); + } +} diff --git a/startup.php b/startup.php new file mode 100644 index 0000000..0166aec --- /dev/null +++ b/startup.php @@ -0,0 +1,74 @@ + ['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'); diff --git a/templates/auth/field.html b/templates/auth/field.html new file mode 100644 index 0000000..de57e19 --- /dev/null +++ b/templates/auth/field.html @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/templates/auth/login.html b/templates/auth/login.html new file mode 100644 index 0000000..a6f2e95 --- /dev/null +++ b/templates/auth/login.html @@ -0,0 +1,11 @@ +

Log in

+
+ :auth_error + :auth_fields + +
+ +
+
diff --git a/templates/auth/login2.html b/templates/auth/login2.html new file mode 100644 index 0000000..277bb0e --- /dev/null +++ b/templates/auth/login2.html @@ -0,0 +1,17 @@ +

Log in

+
+ :auth_error +
+ + +
+
+ diff --git a/templates/auth/register.html b/templates/auth/register.html new file mode 100644 index 0000000..a662ee7 --- /dev/null +++ b/templates/auth/register.html @@ -0,0 +1,8 @@ +

Register

+
+ :auth_error + :auth_fields +
+ +
+
diff --git a/templates/debug/frame.html b/templates/debug/frame.html new file mode 100644 index 0000000..e69de29 diff --git a/templates/debug/index.html b/templates/debug/index.html new file mode 100644 index 0000000..e0e680a --- /dev/null +++ b/templates/debug/index.html @@ -0,0 +1,15 @@ + + + + + + :type - :msg + + +
+:raw_ex
+
+ + diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..adf7368 --- /dev/null +++ b/templates/error.html @@ -0,0 +1,3 @@ +
+ :error_text +
\ No newline at end of file diff --git a/templates/errors/500.html b/templates/errors/500.html new file mode 100644 index 0000000..6993b23 --- /dev/null +++ b/templates/errors/500.html @@ -0,0 +1,11 @@ + + + + + Error 500 + + +

ytkns is fucking dead

+

:msg

+ + diff --git a/templates/footer.html b/templates/footer.html new file mode 100644 index 0000000..c7fbe80 --- /dev/null +++ b/templates/footer.html @@ -0,0 +1,9 @@ + + + :scripts + + diff --git a/templates/header.html b/templates/header.html new file mode 100644 index 0000000..e3c31e8 --- /dev/null +++ b/templates/header.html @@ -0,0 +1,34 @@ + + + + + + :title + + :head + + +
+
+
+ :menu_user +
+ +
+ :menu_site +
+
+
\ No newline at end of file diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..354f742 --- /dev/null +++ b/templates/home.html @@ -0,0 +1,2 @@ +

this site has nothing in particular to do with splatoon lol, it's just a ytmnd clone

+

for the best experience, enable autoplay for this domain

\ No newline at end of file diff --git a/templates/information-redirect.html b/templates/information-redirect.html new file mode 100644 index 0000000..834846a --- /dev/null +++ b/templates/information-redirect.html @@ -0,0 +1 @@ +
Click here if nothing happens. \ No newline at end of file diff --git a/templates/information.html b/templates/information.html new file mode 100644 index 0000000..27366c1 --- /dev/null +++ b/templates/information.html @@ -0,0 +1,6 @@ +
+ :info_title +
+ :info_content +
+
\ No newline at end of file diff --git a/templates/menu-site-item.html b/templates/menu-site-item.html new file mode 100644 index 0000000..2e9f802 --- /dev/null +++ b/templates/menu-site-item.html @@ -0,0 +1 @@ +:text \ No newline at end of file diff --git a/templates/menu-user-item.html b/templates/menu-user-item.html new file mode 100644 index 0000000..55ba12b --- /dev/null +++ b/templates/menu-user-item.html @@ -0,0 +1 @@ +:text \ No newline at end of file diff --git a/templates/profile/index.html b/templates/profile/index.html new file mode 100644 index 0000000..69f9f19 --- /dev/null +++ b/templates/profile/index.html @@ -0,0 +1,3 @@ +

@:profile_username

+

Zones

+:profile_zones \ No newline at end of file diff --git a/templates/profile/notfound.html b/templates/profile/notfound.html new file mode 100644 index 0000000..d206bd9 --- /dev/null +++ b/templates/profile/notfound.html @@ -0,0 +1,2 @@ +

User not found!

+

No user with this username exists.

\ No newline at end of file diff --git a/templates/profile/zone-item.html b/templates/profile/zone-item.html new file mode 100644 index 0000000..cda0017 --- /dev/null +++ b/templates/profile/zone-item.html @@ -0,0 +1,16 @@ +
+ + :zone_name + +
+ + +
+ :zone_views views +
+
+
\ No newline at end of file diff --git a/templates/profile/zone-list.html b/templates/profile/zone-list.html new file mode 100644 index 0000000..8c7bb08 --- /dev/null +++ b/templates/profile/zone-list.html @@ -0,0 +1,3 @@ +
+:zone_items +
\ No newline at end of file diff --git a/templates/profile/zone-none.html b/templates/profile/zone-none.html new file mode 100644 index 0000000..86d0d84 --- /dev/null +++ b/templates/profile/zone-none.html @@ -0,0 +1 @@ +

This user has no zones yet.

\ No newline at end of file diff --git a/templates/settings/index.html b/templates/settings/index.html new file mode 100644 index 0000000..831a751 --- /dev/null +++ b/templates/settings/index.html @@ -0,0 +1 @@ +

Settings

\ No newline at end of file diff --git a/templates/settings/invites-create.html b/templates/settings/invites-create.html new file mode 100644 index 0000000..87c6fcf --- /dev/null +++ b/templates/settings/invites-create.html @@ -0,0 +1,4 @@ +
+ + +
\ No newline at end of file diff --git a/templates/settings/invites-item.html b/templates/settings/invites-item.html new file mode 100644 index 0000000..0fc377f --- /dev/null +++ b/templates/settings/invites-item.html @@ -0,0 +1,14 @@ +
+
+ :invite_created +
+
+ :invite_used +
+
+ :invite_user +
+
+ :invite_token +
+
\ No newline at end of file diff --git a/templates/settings/invites.html b/templates/settings/invites.html new file mode 100644 index 0000000..9cbc070 --- /dev/null +++ b/templates/settings/invites.html @@ -0,0 +1,22 @@ +

Invites

+
+ :invite_error + :invite_create +
+
+
+ Created on +
+
+ Used on +
+
+ Used by +
+
+ Invitation +
+
+ :invite_list +
+
diff --git a/templates/zones/create.html b/templates/zones/create.html new file mode 100644 index 0000000..eb8552b --- /dev/null +++ b/templates/zones/create.html @@ -0,0 +1,20 @@ +

Create a Zone

+
+ :create_error + + + +
+ +
+
\ No newline at end of file diff --git a/templates/zones/delete.html b/templates/zones/delete.html new file mode 100644 index 0000000..2b3bf41 --- /dev/null +++ b/templates/zones/delete.html @@ -0,0 +1,8 @@ +

Deleting Zone - :delete_zone_name

+
+ + +
\ No newline at end of file diff --git a/templates/zones/edit.html b/templates/zones/edit.html new file mode 100644 index 0000000..7b27fe7 --- /dev/null +++ b/templates/zones/edit.html @@ -0,0 +1,25 @@ + +
+
+
+
+ Loading editor... +
+
+
+ \ No newline at end of file diff --git a/templates/zones/item.html b/templates/zones/item.html new file mode 100644 index 0000000..3410b24 --- /dev/null +++ b/templates/zones/item.html @@ -0,0 +1,16 @@ +
+ + :zone_name + +
+ + +
+ :zone_views views - @:zone_user_name +
+
+
\ No newline at end of file diff --git a/templates/zones/list.html b/templates/zones/list.html new file mode 100644 index 0000000..28b8995 --- /dev/null +++ b/templates/zones/list.html @@ -0,0 +1,7 @@ +

:zone_list_title

+
+:zone_sortings +
+
+:zone_list +
\ No newline at end of file diff --git a/templates/zones/my-item.html b/templates/zones/my-item.html new file mode 100644 index 0000000..b0709ad --- /dev/null +++ b/templates/zones/my-item.html @@ -0,0 +1,33 @@ + +
+ + :zone_name + + +
\ No newline at end of file diff --git a/templates/zones/my-list.html b/templates/zones/my-list.html new file mode 100644 index 0000000..4f6c7e8 --- /dev/null +++ b/templates/zones/my-list.html @@ -0,0 +1,4 @@ +

My Zones

+
+:zone_list +
\ No newline at end of file diff --git a/templates/zones/my-none.html b/templates/zones/my-none.html new file mode 100644 index 0000000..6246461 --- /dev/null +++ b/templates/zones/my-none.html @@ -0,0 +1,9 @@ +

My Zones

+
+
+ You have no zones yet. +
+ +
\ No newline at end of file diff --git a/templates/zones/none.html b/templates/zones/none.html new file mode 100644 index 0000000..3568c25 --- /dev/null +++ b/templates/zones/none.html @@ -0,0 +1,4 @@ +
+

This zone doesn't exist!

+

Would you like to create it?

+
\ No newline at end of file