Merge branch 'dev' into 'master'

4.0.3

See merge request crafty-controller/crafty-4!368
This commit is contained in:
Iain Powrie 2022-06-18 22:30:42 +00:00
commit 8514d13320
14 changed files with 308 additions and 186 deletions

View File

@ -21,7 +21,7 @@ win-dev-build:
- pyinstaller -F main.py
--distpath .
--icon app\frontend\static\assets\images\Crafty_4-0_Logo_square.ico
--name "crafty_commander"
--name "crafty"
--paths .venv\Lib\site-packages
--hidden-import cryptography
--hidden-import cffi
@ -37,7 +37,7 @@ win-dev-build:
name: "crafty-${CI_RUNNER_TAGS}-${CI_COMMIT_BRANCH}_${CI_COMMIT_SHORT_SHA}"
paths:
- app\
- .\crafty_commander.exe
- .\crafty.exe
exclude:
- app\classes\**\*
@ -49,7 +49,6 @@ win-prod-build:
paths:
- .venv/
rules:
- if: "$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH"
- if: $CI_COMMIT_TAG
environment:
name: production
@ -63,7 +62,7 @@ win-prod-build:
- pyinstaller -F main.py
--distpath .
--icon app\frontend\static\assets\images\Crafty_4-0_Logo_square.ico
--name "crafty_commander"
--name "crafty"
--paths .venv\Lib\site-packages
--hidden-import cryptography
--hidden-import cffi
@ -81,7 +80,7 @@ win-prod-build:
name: "crafty-${CI_RUNNER_TAGS}-${CI_COMMIT_BRANCH}_${CI_COMMIT_SHORT_SHA}"
paths:
- app\
- .\crafty_commander.exe
- .\crafty.exe
expire_in: never
exclude:
- app\classes\**\*

View File

@ -232,6 +232,8 @@ function-naming-style=snake_case
good-names=e,
ex,
f,
fd,
fn,
i,
id,
ip,

View File

@ -1,9 +1,20 @@
# Changelog
## [4.0.3] - 2022/06/18
### New features
- Integrate Wiki iframe into panel instead of link ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/367))
### Bug fixes
- Amend Java system variable fix to be more specfic since they only affect Oracle. ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/364))
- API Token authentication hardening ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/364))
### Tweaks
- Add better error logging for statistic collection ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/359))
## [4.0.2-hotfix1] - 2022/06/17
### Crit Bug fixes
Fix blank server_detail page for general users ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/358))
- Fix blank server_detail page for general users ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/358))
## [4.0.2] - 2022/06/16

View File

@ -2,11 +2,11 @@
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![Supported Python Versions](https://shields.io/badge/python-3.8%20%7C%203.9%20%7C%203.10%20-blue)](https://www.python.org)
[![Version(temp-hardcoded)](https://img.shields.io/badge/release-v4.0.2--beta-orange)](https://gitlab.com/crafty-controller/crafty-4/-/releases)
[![Version(temp-hardcoded)](https://img.shields.io/badge/release-v4.0.3--beta-orange)](https://gitlab.com/crafty-controller/crafty-4/-/releases)
[![Code Quality(temp-hardcoded)](https://img.shields.io/badge/code%20quality-10-brightgreen)](https://gitlab.com/crafty-controller/crafty-4)
[![Build Status](https://gitlab.com/crafty-controller/crafty-4/badges/master/pipeline.svg)](https://gitlab.com/crafty-controller/crafty-4/-/commits/master)
# Crafty Controller 4.0.2-beta
# Crafty Controller 4.0.3-beta
> Python based Control Panel for your Minecraft Server
## What is Crafty Controller?
@ -39,7 +39,7 @@ With `Crafty Controller 4.0` we have focused on building our DevOps Principles,
> __**⚠ 🔻WARNING: [WSL/WSL2 | WINDOWS 11 | DOCKER DESKTOP]🔻**__ <br>
BE ADVISED! Upstream is currently broken for Minecraft running on **Docker under WSL/WSL2, Windows 11 / DOCKER DESKTOP!** <br>
On '**Stop**' or '**Restart**' of the MC Server, there is a 90% chance the World's Chunks will be shredded irreparably! <br>
Please only run Docker on Linux, If you are using Windows we have a portable installs found here: [Latest-Stable](https://gitlab.com/crafty-controller/crafty-4/-/jobs/artifacts/master/download?job=win-prod-build), [Latest-Development](https://gitlab.com/crafty-controller/crafty-4/-/jobs/artifacts/dev/download?job=win-dev-build)
Please only run Docker on Linux, If you are using Windows we have a portable installs found here: [Latest-Stable](https://gitlab.com/crafty-controller/crafty-4/-/releases), [Latest-Development](https://gitlab.com/crafty-controller/crafty-4/-/jobs/artifacts/dev/download?job=win-dev-build)
----

View File

@ -63,7 +63,9 @@ class Stats:
psutil.boot_time(), datetime.timezone.utc
)
except Exception as e:
logger.debug(f"error while getting boot time due to {e}")
logger.debug(
"getting boot time failed due to the following error:", exc_info=e
)
# unix epoch with no timezone data
return datetime.datetime.fromtimestamp(0, datetime.timezone.utc)
@ -72,7 +74,9 @@ class Stats:
try:
return psutil.cpu_percent(interval=0.5) / psutil.cpu_count()
except Exception as e:
logger.debug(f"error while getting cpu percentage due to {e}")
logger.debug(
"getting the cpu usage failed due to the following error:", exc_info=e
)
return -1
def __init__(self, helper, controller):
@ -100,7 +104,9 @@ class Stats:
"disk_data": Stats._try_all_disk_usage(),
}
except Exception as e:
logger.debug(f"error while getting host stats due to {e}")
logger.debug(
"getting host stats failed due to the following error:", exc_info=e
)
node_stats: NodeStatsDict = {
"boot_time": str(
datetime.datetime.fromtimestamp(0, datetime.timezone.utc)
@ -124,50 +130,50 @@ class Stats:
}
@staticmethod
def _try_get_process_stats(process):
try:
return Stats._get_process_stats(process)
except Exception as e:
logger.debug(f"error while getting process stats due to {e}")
return {"cpu_usage": -1, "memory_usage": -1, "mem_percentage": -1}
def _try_get_process_stats(process, running):
if running:
try:
return Stats._get_process_stats(process)
except Exception as e:
logger.debug(
f"getting process stats for pid {process.pid} "
"failed due to the following error:",
exc_info=e,
)
return {"cpu_usage": -1, "memory_usage": -1, "mem_percentage": -1}
else:
return {"cpu_usage": 0, "memory_usage": 0, "mem_percentage": 0}
@staticmethod
def _get_process_stats(process):
if process is None:
return {"cpu_usage": 0, "memory_usage": 0, "mem_percentage": 0}
return {"cpu_usage": -1, "memory_usage": -1, "mem_percentage": -1}
process_pid = process.pid
try:
p = psutil.Process(process_pid)
dummy = p.cpu_percent()
p = psutil.Process(process_pid)
_dummy = p.cpu_percent()
# call it first so we can be more accurate per the docs
# https://giamptest.readthedocs.io/en/latest/#psutil.Process.cpu_percent
# call it first so we can be more accurate per the docs
# https://giamptest.readthedocs.io/en/latest/#psutil.Process.cpu_percent
real_cpu = round(p.cpu_percent(interval=0.5) / psutil.cpu_count(), 2)
real_cpu = round(p.cpu_percent(interval=0.5) / psutil.cpu_count(), 2)
# this is a faster way of getting data for a process
with p.oneshot():
process_stats = {
"cpu_usage": real_cpu,
"memory_usage": Helpers.human_readable_file_size(
p.memory_info()[0]
),
"mem_percentage": round(p.memory_percent(), 0),
}
return process_stats
except Exception as e:
logger.error(
f"Unable to get process details for pid: {process_pid} Error: {e}"
)
return {"cpu_usage": 0, "memory_usage": 0, "mem_percentage": 0}
# this is a faster way of getting data for a process
with p.oneshot():
process_stats = {
"cpu_usage": real_cpu,
"memory_usage": Helpers.human_readable_file_size(p.memory_info()[0]),
"mem_percentage": round(p.memory_percent(), 0),
}
return process_stats
@staticmethod
def _try_all_disk_usage():
try:
return Stats._all_disk_usage()
except Exception as e:
logger.debug(f"error while getting disk data due to {e}")
logger.debug(
"getting disk stats failed due to the following error:", exc_info=e
)
return []
# Source: https://github.com/giampaolo/psutil/blob/master/scripts/disk_usage.py
@ -246,14 +252,19 @@ class Stats:
online_stats = json.loads(ping_obj.players)
except Exception as e:
logger.info(f"Unable to read json from ping_obj: {e}")
logger.info(
"Unable to read json from ping_obj due to the following error:",
exc_info=e,
)
try:
server_icon = base64.encodebytes(ping_obj.icon)
server_icon = server_icon.decode("utf-8")
except Exception as e:
server_icon = False
logger.info(f"Unable to read the server icon : {e}")
logger.info(
"Unable to read the server icon due to the following error:", exc_info=e
)
ping_data = {
"online": online_stats.get("online", 0),
@ -273,7 +284,9 @@ class Stats:
server_icon = base64.encodebytes(ping_obj["icon"])
except Exception as e:
server_icon = False
logger.info(f"Unable to read the server icon : {e}")
logger.info(
"Unable to read the server icon due to the following error:", exc_info=e
)
ping_data = {
"online": ping_obj["server_player_count"],
"max": ping_obj["server_player_max"],

View File

@ -239,18 +239,23 @@ class ServerInstance:
"Detected nebulous java in start command. "
"Replacing with full java path."
)
which_java_raw = self.helper.which_java()
java_path = which_java_raw + "\\bin\\java"
if str(which_java_raw) != str(self.helper.get_servers_root_dir) or str(
self.helper.get_servers_root_dir
) in str(which_java_raw):
self.server_command[0] = java_path
else:
logger.critcal(
"Possible attack detected. User attempted to exec "
"java binary from server directory."
# Checks for Oracle Java. Only Oracle Java's helper will cause a re-exec.
if "/Oracle/Java/" in str(shutil.which("java")):
logger.info(
"Oracle Java detected. Changing start command to avoid re-exec."
)
return
which_java_raw = self.helper.which_java()
java_path = which_java_raw + "\\bin\\java"
if str(which_java_raw) != str(self.helper.get_servers_root_dir) or str(
self.helper.get_servers_root_dir
) in str(which_java_raw):
self.server_command[0] = java_path
else:
logger.critcal(
"Possible attack detected. User attempted to exec "
"java binary from server directory."
)
return
self.server_path = Helpers.get_os_understandable_path(self.settings["path"])
# let's do some quick checking to make sure things actually exists
@ -1250,7 +1255,7 @@ class ServerInstance:
server_path = server["path"]
# process stats
p_stats = Stats._try_get_process_stats(self.process)
p_stats = Stats._try_get_process_stats(self.process, self.check_running())
# TODO: search server properties file for possible override of 127.0.0.1
internal_ip = server["server_ip"]
@ -1383,7 +1388,7 @@ class ServerInstance:
server_path = server_dt["path"]
# process stats
p_stats = Stats._try_get_process_stats(self.process)
p_stats = Stats._try_get_process_stats(self.process, self.check_running())
# TODO: search server properties file for possible override of 127.0.0.1
# internal_ip = server['server_ip']

View File

@ -1058,6 +1058,11 @@ class PanelHandler(BaseHandler):
if user_id is None:
self.redirect("/panel/error?error=Invalid User ID")
return
if int(user_id) != exec_user["user_id"] and not exec_user["superuser"]:
self.redirect(
"/panel/error?error=You are not authorized to view this page."
)
return
template = "panel/panel_edit_user_apikeys.html"
@ -1222,6 +1227,9 @@ class PanelHandler(BaseHandler):
self.download_file(name, file)
self.redirect(f"/panel/server_detail?id={server_id}&subpage=files")
elif page == "wiki":
template = "panel/wiki.html"
elif page == "download_support_package":
temp_zip_storage = exec_user["support_logs"]
@ -1893,6 +1901,13 @@ class PanelHandler(BaseHandler):
self.redirect("/panel/error?error=Invalid User ID")
return
if str(user_id) != str(exec_user["user_id"]) and not exec_user["superuser"]:
self.redirect(
"/panel/error?error=You do not have access to change"
+ "this user's api key."
)
return
crafty_permissions_mask = self.get_perms()
server_permissions_mask = self.get_perms_server()
@ -1926,6 +1941,12 @@ class PanelHandler(BaseHandler):
self.redirect("/panel/error?error=Invalid Key ID")
return
if key.user_id != exec_user["user_id"]:
self.redirect(
"/panel/error?error=You are not authorized to access this key."
)
return
self.controller.management.add_to_audit_log(
exec_user["user_id"],
f"Generated a new API token for the key {key.name} "
@ -2142,6 +2163,15 @@ class PanelHandler(BaseHandler):
self.redirect("/panel/error?error=Invalid Key ID")
return
key_obj = self.controller.users.get_user_api_key(key_id)
if key_obj.user_id != exec_user["user_id"] and not exec_user["superuser"]:
self.redirect(
"/panel/error?error=You do not have access to change"
+ "this user's api key."
)
return
self.controller.users.delete_user_api_key(key_id)
self.controller.management.add_to_audit_log(
@ -2151,7 +2181,8 @@ class PanelHandler(BaseHandler):
server_id=0,
source_ip=self.get_remote_ip(),
)
self.redirect("/panel/panel_config")
self.finish()
self.redirect(f"/panel/edit_user_apikeys?id={key_obj.user_id}")
else:
self.set_status(404)
self.render(

View File

@ -1,6 +1,6 @@
{
"major": 4,
"minor": 0,
"sub": 2,
"sub": 3,
"meta": "beta"
}

View File

@ -1,133 +1,144 @@
<!-- partial -->
<div class="container-fluid page-body-wrapper">
<!-- partial:partials/_sidebar.html -->
<style>
@media screen and (max-width: 991px) {
.sidebar-offcanvas {
-webkit-transition: all 0.25s cubic-bezier(.22,.61,.36,1);
transition: all 0.25s cubic-bezier(.22,.61,.36,1);
box-shadow: 0px 8px 17px 2px rgba(0,0,0,0.14) , 0px 3px 14px 2px rgba(0,0,0,0.12) , 0px 5px 5px -3px rgba(0,0,0,0.2);
}
}
</style>
<script>
function debounce(func, wait, immediate) {
var timeout;
return function() {
var context = this, args = arguments;
var later = function() {
timeout = null;
if (!immediate) func.apply(context, args);
};
var callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func.apply(context, args);
};
};
function isExtraLargeBreakpoint() {
const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
return vw >= 1200;
}
function isLargeBreakpoint() {
const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
return vw >= 992;
}
$(document).ready(function() {
sidebarResizeHandler(null);
$(window).on(
'resize',
debounce(sidebarResizeHandler, 25, true)
);
});
function sidebarResizeHandler(e) {
/*
Viewport sizes: Extra large (vw >= 1200px), large (vw >= 992px), medium (vw >= 768px)
- A localstorage item is set to remember a user's preference between collapsed or expanded.
- For extra large viewports, the sidebar is the user's preference (by default expanded). When
expanded or collapsed manually, it doesn't overlap the page content and the preference
gets saved to a localstorage item.
- For large viewports, the sidebar is collapsed. When expanded manually, it doesn't overlap
the page content. The user's localstorage preference is not overridden during this state.
- For medium and below viewports, the sidebar is hidden behing a hamburger icon. When expanded, the sidebar
overlaps the page content. The user's localstorage preference is not overridden during this state.
<div class="container-fluid page-body-wrapper">
<!-- partial:partials/_sidebar.html -->
<style>
@media screen and (max-width: 991px) {
.sidebar-offcanvas {
-webkit-transition: all 0.25s cubic-bezier(.22, .61, .36, 1);
transition: all 0.25s cubic-bezier(.22, .61, .36, 1);
box-shadow: 0px 8px 17px 2px rgba(0, 0, 0, 0.14), 0px 3px 14px 2px rgba(0, 0, 0, 0.12), 0px 5px 5px -3px rgba(0, 0, 0, 0.2);
}
}
</style>
<script>
function debounce(func, wait, immediate) {
var timeout;
return function () {
var context = this, args = arguments;
var later = function () {
timeout = null;
if (!immediate) func.apply(context, args);
};
var callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func.apply(context, args);
};
};
function isExtraLargeBreakpoint() {
const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
return vw >= 1200;
}
function isLargeBreakpoint() {
const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
return vw >= 992;
}
$(document).ready(function () {
sidebarResizeHandler(null);
$(window).on(
'resize',
debounce(sidebarResizeHandler, 25, true)
);
});
function sidebarResizeHandler(e) {
/*
Viewport sizes: Extra large (vw >= 1200px), large (vw >= 992px), medium (vw >= 768px)
- A localstorage item is set to remember a user's preference between collapsed or expanded.
- For extra large viewports, the sidebar is the user's preference (by default expanded). When
expanded or collapsed manually, it doesn't overlap the page content and the preference
gets saved to a localstorage item.
- For large viewports, the sidebar is collapsed. When expanded manually, it doesn't overlap
the page content. The user's localstorage preference is not overridden during this state.
- For medium and below viewports, the sidebar is hidden behing a hamburger icon. When expanded, the sidebar
overlaps the page content. The user's localstorage preference is not overridden during this state.
More code in `app/frontend/static/assets/js/shared/misc.js` and `app/frontend/templates/base.html`
*/
if (isExtraLargeBreakpoint()) {
let value = localStorage.getItem('crafty-sidebar-expanded') !== 'false';
$('body').toggleClass('sidebar-icon-only', !value);
localStorage.setItem('crafty-sidebar-expanded', value);
} else if (isLargeBreakpoint()) {
$('body').toggleClass('sidebar-icon-only', true);
}
}
</script>
<nav class="sidebar sidebar-offcanvas" id="sidebar">
<ul class="nav">
More code in `app/frontend/static/assets/js/shared/misc.js` and `app/frontend/templates/base.html`
*/
if (isExtraLargeBreakpoint()) {
let value = localStorage.getItem('crafty-sidebar-expanded') !== 'false';
$('body').toggleClass('sidebar-icon-only', !value);
localStorage.setItem('crafty-sidebar-expanded', value);
} else if (isLargeBreakpoint()) {
$('body').toggleClass('sidebar-icon-only', true);
}
}
</script>
<nav class="sidebar sidebar-offcanvas" id="sidebar">
<ul class="nav">
<li class="nav-item nav-category" style="margin-top:10px;">{{ translate('sidebar', 'navigation', data['lang']) }}</li>
<li class="nav-item nav-category" style="margin-top:10px;">{{ translate('sidebar', 'navigation', data['lang']) }}
</li>
<li class="nav-item">
<a class="nav-link" href="/panel/dashboard">
<i class="fas fa-chart-network"></i>&nbsp;
<span class="menu-title">{{ translate('sidebar', 'dashboard', data['lang']) }}</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-toggle="collapse" href="#page-layouts" aria-expanded="false"
aria-controls="page-layouts">
<i class="fas fa-server"></i> &nbsp;
<span class="menu-title">{{ translate('sidebar', 'servers', data['lang']) }}</span>
<i class="menu-arrow"></i>
</a>
<div class="collapse" id="page-layouts">
<ul class="nav flex-column sub-menu">
{% if data['crafty_permissions']['Server_Creation'] in data['user_crafty_permissions'] %}
<li class="nav-item">
<a class="nav-link" href="/panel/dashboard">
<i class="fas fa-chart-network"></i>&nbsp;
<span class="menu-title">{{ translate('sidebar', 'dashboard', data['lang']) }}</span>
</a>
<a class="nav-link" href="/server/step1"><i class="fas fa-plus-circle"></i> &nbsp; {{ translate('sidebar',
'newServer', data['lang']) }}</a>
</li>
{% end %}
{% for s in data['menu_servers'] %}
<li class="nav-item">
<a class="nav-link" data-toggle="collapse" href="#page-layouts" aria-expanded="false" aria-controls="page-layouts">
<i class="fas fa-server"></i> &nbsp;
<span class="menu-title">{{ translate('sidebar', 'servers', data['lang']) }}</span>
<i class="menu-arrow"></i>
</a>
<div class="collapse" id="page-layouts">
<ul class="nav flex-column sub-menu">
{% if data['crafty_permissions']['Server_Creation'] in data['user_crafty_permissions'] %}
<li class="nav-item">
<a class="nav-link" href="/server/step1"><i class="fas fa-plus-circle"></i> &nbsp; {{ translate('sidebar', 'newServer', data['lang']) }}</a>
</li>
{% end %}
{% for s in data['menu_servers'] %}
<li class="nav-item">
<a class="nav-link" href="/panel/server_detail?id={{s['server_id']}}"><i class="fas fa-server"></i> &nbsp; {{s['server_name']}}</a>
</li>
{% end %}
</ul>
</div>
</li>
<li class="nav-item">
<a class="nav-link" href="https://wiki.craftycontrol.com" target="_blank">
<i class="fas fa-book"></i> &nbsp;
<span class="menu-title">{{ translate('sidebar', 'documentation', data['lang']) }}</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="https://discord.gg/9VJPhCE" target="_blank">
<i class="fab fa-discord"></i> &nbsp;
<span class="menu-title">Discord</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/panel/credits">
<i class="fas fa-heart"></i> &nbsp;
<span class="menu-title">{{ translate('sidebar', 'credits', data['lang']) }}</span>
</a>
</li>
{% if data['show_contribute'] %}
<li class="nav-item">
<a class="nav-link" href="/panel/contribute">
<i class="fas fa-donate"></i> &nbsp;
<span class="menu-title">{{ translate('sidebar', 'contribute', data['lang']) }}</span>
</a>
<a class="nav-link" href="/panel/server_detail?id={{s['server_id']}}"><i class="fas fa-server"></i> &nbsp;
{{s['server_name']}}</a>
</li>
{% end %}
</ul>
</nav>
<!-- partial -->
</div>
</li>
<li class="nav-item">
<a class="nav-link" href="https://wiki.craftycontrol.com" target="_blank">
<i class="fas fa-book"></i> &nbsp;
<span class="menu-title">{{ translate('sidebar', 'documentation', data['lang']) }}</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/panel/wiki">
<i class="fa fa-info-circle"></i> &nbsp;
<span class="menu-title">Wiki</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="https://discord.gg/9VJPhCE" target="_blank">
<i class="fab fa-discord"></i> &nbsp;
<span class="menu-title">Discord</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/panel/credits">
<i class="fas fa-heart"></i> &nbsp;
<span class="menu-title">{{ translate('sidebar', 'credits', data['lang']) }}</span>
</a>
</li>
{% if data['show_contribute'] %}
<li class="nav-item">
<a class="nav-link" href="/panel/contribute">
<i class="fas fa-donate"></i> &nbsp;
<span class="menu-title">{{ translate('sidebar', 'contribute', data['lang']) }}</span>
</a>
</li>
{% end %}
</ul>
</nav>
<!-- partial -->

View File

@ -4,7 +4,7 @@
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Crafty Commander</title>
<title>Crafty Controller</title>
<!-- plugins:css -->
<link rel="stylesheet" href="/static/assets/vendors/mdi/css/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/assets/vendors/flag-icon-css/css/flag-icon.min.css">

View File

@ -0,0 +1,50 @@
{% extends ../base.html %}
{% block meta %}
{% end %}
{% block title %}Crafty Controller - Wiki{% end %}
{% block content %}
<div class="content-wrapper">
<!-- Page Title Header Starts-->
<div class="row page-title-header">
<div class="col-12">
<div class="page-header">
<h4 class="page-title">Wiki</h4>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12 grid-margin">
<iframe src="https://wiki.craftycontrol.com" width=100% height=2200px title="crafty's wiki"></iframe>
</div>
</div>
<!-- content-wrapper ends -->
<style>
.popover-body {
color: white !important;
;
}
#desc_id {
-ms-overflow-style: none;
/* for Internet Explorer, Edge */
scrollbar-width: none;
/* for Firefox */
overflow-y: scroll;
}
#desc_id::-webkit-scrollbar {
display: none;
/* for Chrome, Safari, and Opera */
}
</style>
{% end %}

View File

@ -4,7 +4,7 @@
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Crafty Commander</title>
<title>Crafty Controller</title>
<!-- plugins:css -->
<link rel="stylesheet" href="/static/assets/vendors/mdi/css/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/assets/vendors/flag-icon-css/css/flag-icon.min.css">

View File

@ -4,7 +4,7 @@
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Crafty Commander</title>
<title>Crafty Controller</title>
<!-- plugins:css -->
<link rel="stylesheet" href="/static/assets/vendors/mdi/css/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/assets/vendors/flag-icon-css/css/flag-icon.min.css">

View File

@ -4,7 +4,7 @@
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Crafty Commander</title>
<title>Crafty Controller</title>
<!-- plugins:css -->
<link rel="stylesheet" href="/static/assets/vendors/mdi/css/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/assets/vendors/flag-icon-css/css/flag-icon.min.css">