diff --git a/.dockerignore b/.dockerignore index 04265a3b..c4a0cf73 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,19 @@ +# docker related docker/ +.dockerignore +Dockerfile +docker-compose.yml +# git & gitlab related .git/ .gitignore +.gitlab-ci.yml +# root +.editorconfig +.pylintrc +.venv +.vscode crafty_commander.exe DBCHANGES.md docker-compose.yml.example diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..c6a6fff7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +# https://editorconfig.org + +root = true + +[*.{js,py,html}] +charset = utf-8 +insert_final_newline = true +# end_of_line = lf + +[*.py] +indent_style = space +indent_size = 4 + +[*.{js,html}] +indent_style = space +indent_size = 2 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index dd00fe09..19b94a44 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,15 +1,47 @@ stages: -- win-dev -- win-prod -- docker-dev -- docker-prod +- test +- prod-deployment +- dev-deployment + +variables: + DOCKER_HOST: tcp://docker:2376 + DOCKER_TLS_CERTDIR: "/certs" + +pylint: + stage: test + image: python:3.7-slim + services: + - name: docker:dind + tags: + - 'docker_testers' + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + - if: '$CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS' + when: never + before_script: + - mkdir -p public/badges public/lint + - echo undefined > public/badges/$CI_JOB_NAME.score + - pip install pylint-gitlab + script: + - pylint --exit-zero --output-format=text $(find -type f -name "*.py" ! -path "**/.venv/**" ! -path "**/app/migrations/**") | tee /tmp/pylint.txt + - sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' /tmp/pylint.txt > public/badges/$CI_JOB_NAME.score + - pylint --exit-zero --output-format=pylint_gitlab.GitlabCodeClimateReporter $(find -type f -name "*.py" ! -path "**/.venv/**" ! -path "**/app/migrations/**") > codeclimate.json + after_script: + - anybadge --overwrite --label $CI_JOB_NAME --value=$(cat public/badges/$CI_JOB_NAME.score) --file=public/badges/$CI_JOB_NAME.svg 4=red 6=orange 8=yellow 10=green + - | + echo "Your score is: $(cat public/badges/$CI_JOB_NAME.score)" + artifacts: + paths: + - public + reports: + codequality: codeclimate.json + when: always docker-build-dev: image: docker:latest services: - name: docker:dind - command: ["--experimental"] - stage: docker-dev + stage: dev-deployment tags: - docker rules: @@ -26,13 +58,15 @@ docker-build-dev: mkdir -p ~/.docker/cli-plugins mv docker-buildx ~/.docker/cli-plugins/docker-buildx docker version - - docker run --rm --privileged multiarch/qemu-user-static --reset -p yes + - docker run --rm --privileged aptman/qus -- -r + - docker run --rm --privileged aptman/qus -s -- -p aarch64 x86_64 - echo $CI_BUILD_TOKEN | docker login -u "$CI_REGISTRY_USER" --password-stdin $CI_REGISTRY script: - | tag=":$CI_COMMIT_REF_SLUG" echo "Running on branch '$CI_COMMIT_BRANCH': tag = $tag" - - docker buildx create --use --name zedBuilder + - docker context create tls-environment + - docker buildx create --name zedBuilder --use tls-environment - docker buildx build --cache-from type=registry,ref="$CI_REGISTRY_IMAGE${tag}" --build-arg BUILDKIT_INLINE_CACHE=1 @@ -42,6 +76,7 @@ docker-build-dev: after_script: - | docker buildx rm zedBuilder && echo "Successfully Stopped builder instance" || echo "Failed to stop builder instance." + docker context rm tls-environment || true echo "Please review multi-arch manifests are present:" docker buildx imagetools inspect "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG" @@ -49,8 +84,7 @@ docker-build-prod: image: docker:latest services: - name: docker:dind - command: ["--experimental"] - stage: docker-prod + stage: prod-deployment tags: - docker rules: @@ -67,13 +101,15 @@ docker-build-prod: mkdir -p ~/.docker/cli-plugins mv docker-buildx ~/.docker/cli-plugins/docker-buildx docker version - - docker run --rm --privileged multiarch/qemu-user-static --reset -p yes + - docker run --rm --privileged aptman/qus -- -r + - docker run --rm --privileged aptman/qus -s -- -p aarch64 x86_64 - echo $CI_BUILD_TOKEN | docker login -u "$CI_REGISTRY_USER" --password-stdin $CI_REGISTRY script: - | tag="" echo "Running on default branch '$CI_DEFAULT_BRANCH': tag = 'latest'" - - docker buildx create --use --name zedBuilder + - docker context create tls-environment + - docker buildx create --name zedBuilder --use tls-environment - docker buildx build --cache-from type=registry,ref="$CI_REGISTRY_IMAGE${tag}" --build-arg BUILDKIT_INLINE_CACHE=1 @@ -83,11 +119,12 @@ docker-build-prod: after_script: - | docker buildx rm zedBuilder && echo "Successfully Stopped builder instance" || echo "Failed to stop builder instance." + docker context rm tls-environment || true echo "Please review multi-arch manifests are present:" docker buildx imagetools inspect "$CI_REGISTRY_IMAGE${tag}" win-dev-build: - stage: win-dev + stage: dev-deployment tags: - win64 cache: @@ -111,7 +148,14 @@ win-dev-build: --paths .venv\Lib\site-packages --hidden-import cryptography --hidden-import cffi + --hidden-import apscheduler + --collect-all tzlocal + --collect-all tzdata + --collect-all pytz + --collect-all six + artifacts: + name: "crafty-${CI_RUNNER_TAGS}-${CI_COMMIT_BRANCH}_${CI_COMMIT_SHORT_SHA}" paths: - app\ - .\crafty_commander.exe @@ -119,9 +163,9 @@ win-dev-build: - app\classes\**\* # Download latest: # | https://gitlab.com/crafty-controller/crafty-commander/-/jobs/artifacts/dev/download?job=win-dev-build - + win-prod-build: - stage: win-prod + stage: prod-deployment tags: - win64 cache: @@ -145,11 +189,18 @@ win-prod-build: --paths .venv\Lib\site-packages --hidden-import cryptography --hidden-import cffi + --hidden-import apscheduler + --collect-all tzlocal + --collect-all tzdata + --collect-all pytz + --collect-all six + artifacts: + name: "crafty-${CI_RUNNER_TAGS}-${CI_COMMIT_BRANCH}_${CI_COMMIT_SHORT_SHA}" paths: - app\ - .\crafty_commander.exe exclude: - app\classes\**\* # Download latest: - # | https://gitlab.com/crafty-controller/crafty-commander/-/jobs/artifacts/master/download?job=win-prod-build \ No newline at end of file + # | https://gitlab.com/crafty-controller/crafty-commander/-/jobs/artifacts/master/download?job=win-prod-build diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 00000000..0dcd80e1 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,603 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-allow-list= + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. (This is an alternative name to extension-pkg-allow-list +# for backward compatibility.) +extension-pkg-whitelist= + +# Return non-zero exit code if any of these messages/categories are detected, +# even if score is above --fail-under value. Syntax same as enable. Messages +# specified are enabled, while categories only check already-enabled messages. +fail-on= + +# Specify a score threshold to be exceeded before program exits with error. +fail-under=10.0 + +# Files or directories to be skipped. They should be base names, not paths. +ignore= + +# Add files or directories matching the regex patterns to the ignore-list. The +# regex matches against paths and can be in Posix or Windows format. +ignore-paths=app/migrations, app/classes/shared/migration.py + +# Files or directories matching the regex patterns are skipped. The regex +# matches against base names, not paths. +ignore-patterns= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use. +jobs=0 + +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 + +# List of plugins (as comma separated values of python module names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# Minimum Python version to use for version dependent checks. Will default to +# the version used to run pylint. +py-version=3.9 + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=abstract-method, + attribute-defined-outside-init, + bad-inline-option, + bare-except, + broad-except, + cell-var-from-loop, + consider-iterating-dictionary, + consider-using-with, + deprecated-pragma, + duplicate-code, + file-ignored, + fixme, + import-error, + inconsistent-return-statements, + invalid-name, + locally-disabled, + logging-format-interpolation, + logging-fstring-interpolation, + logging-not-lazy, + missing-docstring, + no-else-break, + no-else-continue, + no-else-return, + no-self-use, + no-value-for-parameter, + not-an-iterable, + protected-access, + simplifiable-condition, + simplifiable-if-statement, + suppressed-message, + too-few-public-methods, + too-many-arguments, + too-many-branches, + too-many-instance-attributes, + too-many-locals, + too-many-nested-blocks, + too-many-public-methods, + too-many-return-statements, + too-many-statements, + use-symbolic-message-instead, + useless-suppression, + raw-checker-failed + + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member + + +[REPORTS] + +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'error', 'warning', 'refactor', and 'convention' +# which contain the number of messages in each category, as well as 'statement' +# which is the total number of statements analyzed. This score is used by the +# global evaluation report (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=yes + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit,argparse.parse_error + + +[BASIC] + +# Naming style matching correct argument names. +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style. +#argument-rgx= + +# Naming style matching correct attribute names. +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Bad variable names regexes, separated by a comma. If names match any regex, +# they will always be refused +bad-names-rgxs= + +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. +#class-attribute-rgx= + +# Naming style matching correct class constant names. +class-const-naming-style=UPPER_CASE + +# Regular expression matching correct class constant names. Overrides class- +# const-naming-style. +#class-const-rgx= + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming- +# style. +#class-rgx= + +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style. +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style. +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma. +good-names=i, + j, + k, + ex, + Run, + _ + +# Good variable names regexes, separated by a comma. If names match any regex, +# they will always be accepted +good-names-rgxs= + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=no + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. +#inlinevar-rgx= + +# Naming style matching correct method names. +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style. +#method-rgx= + +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. +#variable-rgx= + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=150 + +# Maximum number of lines in a module. +max-module-lines=2000 + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[LOGGING] + +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. +logging-format-style=old + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + +# Regular expression of note tags to take in consideration. +#notes-rgx= + + +[SIMILARITIES] + +# Comments are removed from the similarity computation +ignore-comments=yes + +# Docstrings are removed from the similarity computation +ignore-docstrings=yes + +# Imports are removed from the similarity computation +ignore-imports=no + +# Signatures are removed from the similarity computation +ignore-signatures=no + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +# Spelling dictionary name. Available dictionaries: none. To make it work, +# install the 'python-enchant' package. +spelling-dict= + +# List of comma separated words that should be considered directives if they +# appear and the beginning of a comment and should not be checked. +spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains the private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. +spelling-store-unknown-words=no + + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=no + +# This flag controls whether the implicit-str-concat should generate a warning +# on implicit string concatenation in sequences defined over several lines. +check-str-concat-over-line-jumps=no + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members=os.* + +# Tells whether missing members accessed in mixin class should be ignored. A +# class is considered mixin if its name matches the mixin-class-rgx option. +ignore-mixin-members=yes + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis). It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + +# Regex pattern to define which classes are considered mixins ignore-mixin- +# members is set to 'yes' +mixin-class-rgx=.*[Mm]ixin + +# List of decorators that change the signature of a decorated function. +signature-mutators= + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of names allowed to shadow builtins +allowed-redefined-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io + + +[CLASSES] + +# Warn about protected attribute access inside special methods +check-protected-access-in-special-methods=no + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp, + __post_init__ + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=cls + + +[DESIGN] + +# List of regular expressions of class ancestor names to ignore when counting +# public methods (see R0903) +exclude-too-few-public-methods= + +# List of qualified class names to ignore when counting class parents (see +# R0901) +ignored-parents= + +# Maximum number of arguments for function / method. +max-args=8 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in an if statement (see R0916). +max-bool-expr=5 + +# Maximum number of branch for function / method body. +max-branches=12 + +# Maximum number of locals for function / method body. +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body. +max-returns=6 + +# Maximum number of statements in function / method body. +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[IMPORTS] + +# List of modules that can be imported at any level, not just the top level +# one. +allow-any-import-level= + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules= + +# Output a graph (.gv or any supported image format) of external dependencies +# to the given file (report RP0402 must not be disabled). +ext-import-graph= + +# Output a graph (.gv or any supported image format) of all (i.e. internal and +# external) dependencies to the given file (report RP0402 must not be +# disabled). +import-graph= + +# Output a graph (.gv or any supported image format) of internal dependencies +# to the given file (report RP0402 must not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +# Couples of modules and preferred modules, separated by a comma. +preferred-modules= + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "BaseException, Exception". +overgeneral-exceptions=BaseException, + Exception diff --git a/Dockerfile b/Dockerfile index c27176df..140d5122 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,28 +1,51 @@ -FROM python:alpine +FROM ubuntu:20.04 + +ENV DEBIAN_FRONTEND="noninteractive" LABEL maintainer="Dockerfile created by Zedifus " # Security Patch for CVE-2021-44228 ENV LOG4J_FORMAT_MSG_NO_LOOKUPS=true -# Install Packages, Build Dependencies & Garbage Collect & Harden -# (Alpine Edge repo is needed because jre16 is new) -COPY requirements.txt /commander/requirements.txt -RUN apk add --no-cache -X http://dl-cdn.alpinelinux.org/alpine/edge/community llvm11-libs openssl-dev rust cargo gcc musl-dev libffi-dev make openjdk8-jre-base openjdk11-jre-headless openjdk16-jre-headless mariadb-dev \ -&& pip3 install --no-cache-dir -r /commander/requirements.txt \ -&& apk del --no-cache gcc musl-dev libffi-dev make rust cargo openssl-dev llvm11-libs \ -&& rm -rf /sbin/apk \ -&& rm -rf /etc/apk \ -&& rm -rf /lib/apk \ -&& rm -rf /usr/share/apk \ -&& rm -rf /var/lib/apk +# Create non-root user & required dirs +RUN useradd -g root -M crafty \ + && mkdir /commander \ + && chown -R crafty:root /commander -# Copy Source & copy default config from image -COPY ./ /commander +# Install required system packages +RUN apt-get update \ + && apt-get -y --no-install-recommends install \ + sudo \ + gcc \ + python3 \ + python3-dev \ + python3-pip \ + python3-venv \ + libmariadb-dev \ + default-jre \ + openjdk-8-jre-headless \ + openjdk-11-jre-headless \ + openjdk-16-jre-headless \ + openjdk-17-jre-headless \ + && apt-get autoremove \ + && apt-get clean + +# Switch to service user for installing crafty deps +USER crafty WORKDIR /commander +COPY --chown=crafty:root requirements.txt ./ +RUN python3 -m venv ./.venv \ + && . .venv/bin/activate \ + && pip3 install --no-cache-dir --upgrade setuptools==50.3.2 pip==22.0.3 \ + && pip3 install --no-cache-dir -r requirements.txt \ + && deactivate +USER root + +# Copy Source w/ perms & prepare default config from example +COPY --chown=crafty:root ./ ./ RUN mv ./app/config ./app/config_original \ -&& mv ./app/config_original/default.json.example ./app/config_original/default.json \ -&& chmod +x ./docker_launcher.sh + && mv ./app/config_original/default.json.example ./app/config_original/default.json \ + && chmod +x ./docker_launcher.sh # Expose Web Interface port & Server port range EXPOSE 8000 diff --git a/README.md b/README.md index d7fb8648..b3a05c58 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ a web interface for the server administrators to interact with their servers. Cr is compatible with Docker, Linux, Windows 7, Windows 8 and Windows 10. ## Documentation -Temporary documentation available on [GitLab](https://gitlab.com/crafty-controller/crafty-commander/wikis/home) +Documentation available on [wiki.craftycontrol.com](https://craftycontrol.com) ## Meta Project Homepage - https://craftycontrol.com @@ -17,15 +17,35 @@ Discord Server - https://discord.gg/9VJPhCE Git Repository - https://gitlab.com/crafty-controller/crafty-web -## Basic Docker Usage +
-**To get started with docker**, all you need to do is pull the image from this git repository's registry. -This is done by using `docker-compose` or `docker run`(You don't need to clone the Repository and build, like in 3.x ). +## Basic Docker Usage 🐳 -If you have a config folder already from previous local installation or docker setup, the image should mount this volume, if none is present then it will populate its own config folder for you. +With `Crafty Controller 4.0` we have focused on building our DevOps Principles, implementing build automation, and securing our containers, with the hopes of making our Container user's lives abit easier. -### Using the registry image: -The provided image supports both `arm64` and `amd64` out the box, if you have issues though you can build it yourself. +### - Two big changes you will notice is: +- We now provide pre-built images for you guys. +- Containers now run as non-root, using practices used by OpenShift & Kubernetes (root group perms). + + +> __**⚠ 🔻WARNING: [WSL/WSL2 | WINDOWS 11 | DOCKER DESKTOP]🔻**__
+ BE ADVISED! Upstream is currently broken for Minecraft running on **Docker under WSL/WSL2, Windows 11 / DOCKER DESKTOP!**
+ On '**Stop**' or '**Restart**' of the MC Server, there is a 90% chance the World's Chunks will be shredded irreparably!
+ 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-commander/-/jobs/artifacts/master/download?job=win-prod-build), [Latest-Development](https://gitlab.com/crafty-controller/crafty-commander/-/jobs/artifacts/dev/download?job=win-dev-build) + +---- + +### - To get started with docker 🛫 +All you need to do is pull the image from this git repository's registry. +This is done by using `'docker-compose'` or `'docker run'` (You don't need to clone the Repository and build, like in 3.x ). + +If you have a config folder already from previous local installation or _docker setup_*, the image should mount this volume and fix the permission as required, if no config present then it will populate its own config folder for you.

+As the Dockerfile uses the permission structure of `crafty:root` **internally** there is no need to worry about matching the `UID` or `GID` on the host system :) + +
+ +### - Using the registry image 🌎 +The provided image supports both `arm64` and `amd64` out the box, if you have issues though you can build it yourself with the `compose` file in `docker/`. The image is located at: `registry.gitlab.com/crafty-controller/crafty-commander:latest` | Branch | Status | @@ -50,13 +70,17 @@ or ```bash $ echo | docker login registry.gitlab.com -u --password-stdin ``` -or +or ```bash $ cat ~/my_password.txt | docker login registry.gitlab.com -u --password-stdin ``` Then use one of the following methods: -#### docker-compose.yml +### **docker-compose.yml:** +```sh +# Make your compose file +$ vim docker-compose.yml +``` ```yml version: '3' @@ -64,20 +88,27 @@ services: crafty: container_name: crafty_commander image: registry.gitlab.com/crafty-controller/crafty-commander:latest + environment: + - TZ=Etc/UTC ports: - "8000:8000" # HTTP - "8443:8443" # HTTPS - "8123:8123" # DYNMAP - "19132:19132/udp" # BEDROCK - - "24000-25600:24000-25600" # MC SERV PORT RANGE + - "25500-25600:25500-25600" # MC SERV PORT RANGE volumes: - ./docker/backups:/commander/backups - ./docker/logs:/commander/logs - ./docker/servers:/commander/servers - ./docker/config:/commander/app/config + - ./docker/import:/commander/import ``` +```sh +$ docker-compose up -d && docker-compose logs -f +``` +
-#### docker run +### **docker run:** ```sh $ docker run \ --name crafty_commander \ @@ -85,19 +116,21 @@ $ docker run \ -p 8443:8443 \ -p 8123:8123 \ -p 19132:19132/udp \ - -p 24000-25600:24000-25600 \ + -p 25500-25600:25500-25600 \ + -e TZ=Etc/UTC \ -v "/$(pwd)/docker/backups:/commander/backups" \ -v "/$(pwd)/docker/logs:/commander/logs" \ -v "/$(pwd)/docker/servers:/commander/servers" \ -v "/$(pwd)/docker/config:/commander/app/config" \ + -v "/$(pwd)/docker/import:/commander/import" \ registry.gitlab.com/crafty-controller/crafty-commander:latest ``` -### Building from the cloned repository: +### **Building from the cloned repository:** If you are building from `docker-compose` you can find the compose file in `./docker/docker-compose.yml` just `cd` to the docker directory and `docker-compose up -d` -If you'd rather not use `docker-compose` you can use the following `docker run`in the directory where the *Dockerfile* is: +If you'd rather not use `docker-compose` you can use the following `docker run` in the directory where the *Dockerfile* is: ```sh # REMEMBER, Build your image first! $ docker build . -t crafty @@ -108,11 +141,13 @@ $ docker run \ -p 8443:8443 \ -p 8123:8123 \ -p 19132:19132/udp \ - -p 24000-25600:24000-25600 \ + -p 25500-25600:25500-25600 \ + -e TZ=Etc/UTC \ -v "/$(pwd)/docker/backups:/commander/backups" \ -v "/$(pwd)/docker/logs:/commander/logs" \ -v "/$(pwd)/docker/servers:/commander/servers" \ -v "/$(pwd)/docker/config:/commander/app/config" \ + -v "/$(pwd)/docker/import:/commander/import" \ crafty ``` -A fresh build will take several minutes depending on your system, but will be rapid there after. +A fresh build will take several minutes depending on your system, but will be rapid thereafter. diff --git a/app/classes/controllers/crafty_perms_controller.py b/app/classes/controllers/crafty_perms_controller.py index f9f95f0d..75cf2601 100644 --- a/app/classes/controllers/crafty_perms_controller.py +++ b/app/classes/controllers/crafty_perms_controller.py @@ -1,23 +1,7 @@ -import os -import time import logging -import sys -import yaml -import asyncio -import shutil -import tempfile -import zipfile -from distutils import dir_util -from app.classes.shared.helpers import helper -from app.classes.shared.console import console - -from app.classes.models.crafty_permissions import crafty_permissions, Enum_Permissions_Crafty - -from app.classes.shared.server import Server -from app.classes.minecraft.server_props import ServerProps -from app.classes.minecraft.serverjars import server_jar_obj -from app.classes.minecraft.stats import Stats +from app.classes.models.crafty_permissions import crafty_permissions, Enum_Permissions_Crafty +from app.classes.models.users import ApiKeys logger = logging.getLogger(__name__) @@ -42,15 +26,15 @@ class Crafty_Perms_Controller: return crafty_permissions.can_add_in_crafty(user_id, Enum_Permissions_Crafty.Server_Creation) @staticmethod - def can_add_user(user_id): - #TODO: Complete if we need a User Addition limit - #return crafty_permissions.can_add_in_crafty(user_id, Enum_Permissions_Crafty.User_Config) + def can_add_user(): # Add back argument 'user_id' when you work on this + #TODO: Complete if we need a User Addition limit + #return crafty_permissions.can_add_in_crafty(user_id, Enum_Permissions_Crafty.User_Config) return True @staticmethod - def can_add_role(user_id): - #TODO: Complete if we need a Role Addition limit - #return crafty_permissions.can_add_in_crafty(user_id, Enum_Permissions_Crafty.Roles_Config) + def can_add_role(): # Add back argument 'user_id' when you work on this + #TODO: Complete if we need a Role Addition limit + #return crafty_permissions.can_add_in_crafty(user_id, Enum_Permissions_Crafty.Roles_Config) return True @staticmethod @@ -70,3 +54,7 @@ class Crafty_Perms_Controller: @staticmethod def add_server_creation(user_id): return crafty_permissions.add_server_creation(user_id) + + @staticmethod + def get_api_key_permissions_list(key: ApiKeys): + return crafty_permissions.get_api_key_permissions_list(key) diff --git a/app/classes/controllers/management_controller.py b/app/classes/controllers/management_controller.py index 33eceb1b..59419ca0 100644 --- a/app/classes/controllers/management_controller.py +++ b/app/classes/controllers/management_controller.py @@ -1,25 +1,8 @@ -import os -import time import logging -import sys -import yaml -import asyncio -import shutil -import tempfile -import zipfile -from distutils import dir_util - -from app.classes.shared.helpers import helper -from app.classes.shared.console import console from app.classes.models.management import management_helper from app.classes.models.servers import servers_helper -from app.classes.shared.server import Server -from app.classes.minecraft.server_props import ServerProps -from app.classes.minecraft.serverjars import server_jar_obj -from app.classes.minecraft.stats import Stats - logger = logging.getLogger(__name__) class Management_Controller: @@ -31,10 +14,6 @@ class Management_Controller: def get_latest_hosts_stats(): return management_helper.get_latest_hosts_stats() - @staticmethod - def new_api_token(): - return management_helper.new_api_token() - #************************************************************************************************ # Commands Methods #************************************************************************************************ @@ -44,19 +23,16 @@ class Management_Controller: @staticmethod def send_command(user_id, server_id, remote_ip, command): - server_name = servers_helper.get_server_friendly_name(server_id) # Example: Admin issued command start_server for server Survival - management_helper.add_to_audit_log(user_id, "issued command {} for server {}".format(command, server_name), - server_id, remote_ip) - + management_helper.add_to_audit_log(user_id, f"issued command {command} for server {server_name}", server_id, remote_ip) management_helper.add_command(server_id, user_id, remote_ip, command) @staticmethod def mark_command_complete(command_id=None): return management_helper.mark_command_complete(command_id) - + #************************************************************************************************ # Audit_Log Methods #************************************************************************************************ @@ -77,7 +53,16 @@ class Management_Controller: #************************************************************************************************ @staticmethod def create_scheduled_task(server_id, action, interval, interval_type, start_time, command, comment=None, enabled=True): - return management_helper.create_scheduled_task(server_id, action, interval, interval_type, start_time, command, comment, enabled) + return management_helper.create_scheduled_task( + server_id, + action, + interval, + interval_type, + start_time, + command, + comment, + enabled + ) @staticmethod def delete_scheduled_task(schedule_id): @@ -91,6 +76,14 @@ class Management_Controller: def get_scheduled_task(schedule_id): return management_helper.get_scheduled_task(schedule_id) + @staticmethod + def get_scheduled_task_model(schedule_id): + return management_helper.get_scheduled_task_model(schedule_id) + + @staticmethod + def get_child_schedules(sch_id): + return management_helper.get_child_schedules(sch_id) + @staticmethod def get_schedules_by_server(server_id): return management_helper.get_schedules_by_server(server_id) @@ -111,5 +104,17 @@ class Management_Controller: return management_helper.get_backup_config(server_id) @staticmethod - def set_backup_config(server_id: int, backup_path: str = None, max_backups: int = None, auto_enabled: bool = True): - return management_helper.set_backup_config(server_id, backup_path, max_backups, auto_enabled) + def set_backup_config(server_id: int, backup_path: str = None, max_backups: int = None, excluded_dirs: list = None, compress: bool = False,): + return management_helper.set_backup_config(server_id, backup_path, max_backups, excluded_dirs, compress) + + @staticmethod + def get_excluded_backup_dirs(server_id: int): + return management_helper.get_excluded_backup_dirs(server_id) + + @staticmethod + def add_excluded_backup_dir(server_id: int, dir_to_add: str): + management_helper.add_excluded_backup_dir(server_id, dir_to_add) + + @staticmethod + def del_excluded_backup_dir(server_id: int, dir_to_del: str): + management_helper.del_excluded_backup_dir(server_id, dir_to_del) diff --git a/app/classes/controllers/roles_controller.py b/app/classes/controllers/roles_controller.py index f43de96a..bded6e23 100644 --- a/app/classes/controllers/roles_controller.py +++ b/app/classes/controllers/roles_controller.py @@ -1,25 +1,9 @@ -import os -import time import logging -import sys -import yaml -import asyncio -import shutil -import tempfile -import zipfile -from distutils import dir_util - -from app.classes.shared.helpers import helper -from app.classes.shared.console import console from app.classes.models.roles import roles_helper from app.classes.models.server_permissions import server_permissions from app.classes.models.users import users_helper - -from app.classes.shared.server import Server -from app.classes.minecraft.server_props import ServerProps -from app.classes.minecraft.serverjars import server_jar_obj -from app.classes.minecraft.stats import Stats +from app.classes.shared.helpers import helper logger = logging.getLogger(__name__) @@ -39,11 +23,12 @@ class Roles_Controller: @staticmethod - def update_role(role_id, role_data={}, permissions_mask="00000000"): + def update_role(role_id: str, role_data = None, permissions_mask: str = "00000000"): + if role_data is None: + role_data = {} base_data = Roles_Controller.get_role_with_servers(role_id) up_data = {} added_servers = set() - edited_servers = set() removed_servers = set() for key in role_data: if key == "role_id": @@ -54,7 +39,7 @@ class Roles_Controller: elif base_data[key] != role_data[key]: up_data[key] = role_data[key] up_data['last_update'] = helper.get_time_as_string() - logger.debug("role: {} +server:{} -server{}".format(role_data, added_servers, removed_servers)) + logger.debug(f"role: {role_data} +server:{added_servers} -server{removed_servers}") for server in added_servers: server_permissions.get_or_create(role_id, server, permissions_mask) for server in base_data['servers']: @@ -94,4 +79,4 @@ class Roles_Controller: return role else: #logger.debug("role: ({}) {}".format(role_id, {})) - return {} \ No newline at end of file + return {} diff --git a/app/classes/controllers/server_perms_controller.py b/app/classes/controllers/server_perms_controller.py index 1a15f3c3..bd0ae36d 100644 --- a/app/classes/controllers/server_perms_controller.py +++ b/app/classes/controllers/server_perms_controller.py @@ -1,32 +1,19 @@ -import os -import time import logging -import sys -import yaml -import asyncio -import shutil -import tempfile -import zipfile -from distutils import dir_util -from app.classes.shared.helpers import helper -from app.classes.shared.console import console - -from app.classes.shared.main_models import db_helper from app.classes.models.server_permissions import server_permissions, Enum_Permissions_Server -from app.classes.models.users import users_helper +from app.classes.models.users import users_helper, ApiKeys from app.classes.models.roles import roles_helper from app.classes.models.servers import servers_helper - -from app.classes.shared.server import Server -from app.classes.minecraft.server_props import ServerProps -from app.classes.minecraft.serverjars import server_jar_obj -from app.classes.minecraft.stats import Stats +from app.classes.shared.main_models import db_helper logger = logging.getLogger(__name__) class Server_Perms_Controller: + @staticmethod + def get_server_user_list(server_id): + return server_permissions.get_server_user_list(server_id) + @staticmethod def list_defined_permissions(): permissions_list = server_permissions.get_permissions_list() @@ -42,15 +29,23 @@ class Server_Perms_Controller: permissions_list = server_permissions.get_role_permissions_list(role_id) return permissions_list - @staticmethod - def get_server_permissions_foruser(user_id, server_id): - permissions_list = server_permissions.get_user_permissions_list(user_id, server_id) - return permissions_list - @staticmethod def add_role_server(server_id, role_id, rs_permissions="00000000"): return server_permissions.add_role_server(server_id, role_id, rs_permissions) + @staticmethod + def get_server_roles(server_id): + return server_permissions.get_server_roles(server_id) + + @staticmethod + def backup_role_swap(old_server_id, new_server_id): + role_list = server_permissions.get_server_roles(old_server_id) + for role in role_list: + server_permissions.add_role_server( + new_server_id, role.role_id, + server_permissions.get_permissions_mask(int(role.role_id), int(old_server_id))) + #server_permissions.add_role_server(new_server_id, role.role_id, '00001000') + #************************************************************************************************ # Servers Permissions Methods #************************************************************************************************ @@ -67,8 +62,17 @@ class Server_Perms_Controller: return server_permissions.get_role_permissions_list(role_id) @staticmethod - def get_user_permissions_list(user_id, server_id): - return server_permissions.get_user_permissions_list(user_id, server_id) + def get_user_id_permissions_list(user_id: str, server_id: str): + return server_permissions.get_user_id_permissions_list(user_id, server_id) + + @staticmethod + def get_api_key_id_permissions_list(key_id: str, server_id: str): + key = users_helper.get_user_api_key(key_id) + return server_permissions.get_api_key_permissions_list(key, server_id) + + @staticmethod + def get_api_key_permissions_list(key: ApiKeys, server_id: str): + return server_permissions.get_api_key_permissions_list(key, server_id) @staticmethod def get_authorized_servers_stats_from_roles(user_id): diff --git a/app/classes/controllers/servers_controller.py b/app/classes/controllers/servers_controller.py index c6596cd1..4a596428 100644 --- a/app/classes/controllers/servers_controller.py +++ b/app/classes/controllers/servers_controller.py @@ -1,29 +1,13 @@ -from app.classes.controllers.roles_controller import Roles_Controller import os -import time import logging import json -import sys -import yaml -import asyncio -import shutil -import tempfile -import zipfile -from distutils import dir_util -from app.classes.shared.helpers import helper -from app.classes.shared.console import console - -from app.classes.shared.main_models import db_helper +from app.classes.controllers.roles_controller import Roles_Controller from app.classes.models.servers import servers_helper -from app.classes.models.roles import roles_helper -from app.classes.models.users import users_helper +from app.classes.models.users import users_helper, ApiKeys from app.classes.models.server_permissions import server_permissions, Enum_Permissions_Server - -from app.classes.shared.server import Server -from app.classes.minecraft.server_props import ServerProps -from app.classes.minecraft.serverjars import server_jar_obj -from app.classes.minecraft.stats import Stats +from app.classes.shared.helpers import helper +from app.classes.shared.main_models import db_helper logger = logging.getLogger(__name__) @@ -33,8 +17,48 @@ class Servers_Controller: # Generic Servers Methods #************************************************************************************************ @staticmethod - def create_server(name: str, server_uuid: str, server_dir: str, backup_path: str, server_command: str, server_file: str, server_log_file: str, server_stop: str, server_port=25565): - return servers_helper.create_server(name, server_uuid, server_dir, backup_path, server_command, server_file, server_log_file, server_stop, server_port) + def create_server( + name: str, + server_uuid: str, + server_dir: str, + backup_path: str, + server_command: str, + server_file: str, + server_log_file: str, + server_stop: str, + server_type: str, + server_port=25565): + return servers_helper.create_server( + name, + server_uuid, + server_dir, + backup_path, + server_command, + server_file, + server_log_file, + server_stop, + server_type, + server_port) + + @staticmethod + def get_server_obj(server_id): + return servers_helper.get_server_obj(server_id) + + @staticmethod + def update_server(server_obj): + return servers_helper.update_server(server_obj) + + @staticmethod + def set_download(server_id): + return servers_helper.set_download(server_id) + + @staticmethod + def finish_download(server_id): + return servers_helper.finish_download(server_id) + + @staticmethod + def get_download_status(server_id): + return servers_helper.get_download_status(server_id) @staticmethod def remove_server(server_id): @@ -73,6 +97,22 @@ class Servers_Controller: def get_all_servers_stats(): return servers_helper.get_all_servers_stats() + @staticmethod + def get_authorized_servers_stats_api_key(api_key: ApiKeys): + server_data = [] + authorized_servers = Servers_Controller.get_authorized_servers(api_key.user.user_id) + + for s in authorized_servers: + latest = servers_helper.get_latest_server_stats(s.get('server_id')) + key_permissions = server_permissions.get_api_key_permissions_list(api_key, s.get('server_id')) + if Enum_Permissions_Server.Commands in key_permissions: + user_command_permission = True + else: + user_command_permission = False + server_data.append({'server_data': s, "stats": db_helper.return_rows(latest)[0], + "user_command_permission": user_command_permission}) + return server_data + @staticmethod def get_authorized_servers_stats(user_id): server_data = [] @@ -80,12 +120,18 @@ class Servers_Controller: for s in authorized_servers: latest = servers_helper.get_latest_server_stats(s.get('server_id')) - user_permissions = server_permissions.get_user_permissions_list(user_id, s.get('server_id')) + # TODO + user_permissions = server_permissions.get_user_id_permissions_list(user_id, s.get('server_id')) if Enum_Permissions_Server.Commands in user_permissions: user_command_permission = True else: user_command_permission = False - server_data.append({'server_data': s, "stats": db_helper.return_rows(latest)[0], "user_command_permission":user_command_permission}) + server_data.append({ + 'server_data': s, + 'stats': db_helper.return_rows(latest)[0], + 'user_command_permission': user_command_permission + }) + return server_data @staticmethod @@ -104,17 +150,28 @@ class Servers_Controller: return servers_helper.server_id_exists(server_id) @staticmethod - def server_id_authorized(serverId, user_id): - authorized = 0 + def get_server_type_by_id(server_id): + return servers_helper.get_server_type_by_id(server_id) + + @staticmethod + def server_id_authorized(server_id_a, user_id): user_roles = users_helper.user_role_query(user_id) for role in user_roles: - authorized = server_permissions.get_role_servers_from_role_id(role.role_id) + for server_id_b in server_permissions.get_role_servers_from_role_id(role.role_id): + if str(server_id_a) == str(server_id_b.server_id): + return True + return False - #authorized = db_helper.return_rows(authorized) + @staticmethod + def is_crashed(server_id): + return servers_helper.is_crashed(server_id) - if authorized.count() == 0: - return False - return True + @staticmethod + def server_id_authorized_api_key(server_id: str, api_key: ApiKeys) -> bool: + # TODO + return Servers_Controller.server_id_authorized(server_id, api_key.user.user_id) + # There is no view server permission + # permission_helper.both_have_perm(api_key) @staticmethod def set_update(server_id, value): @@ -136,6 +193,10 @@ class Servers_Controller: def get_waiting_start(server_id): return servers_helper.get_waiting_start(server_id) + @staticmethod + def get_update_status(server_id): + return servers_helper.get_update_status(server_id) + #************************************************************************************************ # Servers Helpers Methods #************************************************************************************************ @@ -146,7 +207,7 @@ class Servers_Controller: path = os.path.join(server_path, 'banned-players.json') try: - with open(path) as file: + with open(helper.get_os_understandable_path(path), encoding='utf-8') as file: content = file.read() file.close() except Exception as ex: @@ -170,7 +231,6 @@ class Servers_Controller: )) for log_file in log_files: log_file_path = os.path.join(logs_path, log_file) - if self.check_file_exists(log_file_path) and \ - self.is_file_older_than_x_days(log_file_path, logs_delete_after): + if helper.check_file_exists(log_file_path) and \ + helper.is_file_older_than_x_days(log_file_path, logs_delete_after): os.remove(log_file_path) - diff --git a/app/classes/controllers/users_controller.py b/app/classes/controllers/users_controller.py index 3bf146f1..e63ede37 100644 --- a/app/classes/controllers/users_controller.py +++ b/app/classes/controllers/users_controller.py @@ -1,25 +1,15 @@ -import os -import time import logging -import sys -import yaml -import asyncio -import shutil -import tempfile -import zipfile -from distutils import dir_util +from typing import Optional -from app.classes.shared.helpers import helper -from app.classes.shared.console import console - -from app.classes.models.users import Users, users_helper +from app.classes.models.users import users_helper from app.classes.models.crafty_permissions import crafty_permissions, Enum_Permissions_Crafty -from app.classes.models.management import management_helper +from app.classes.shared.helpers import helper +from app.classes.shared.authentication import authentication logger = logging.getLogger(__name__) class Users_Controller: - + #************************************************************************************************ # Users Methods #************************************************************************************************ @@ -31,10 +21,6 @@ class Users_Controller: def get_id_by_name(username): return users_helper.get_user_id_by_name(username) - @staticmethod - def get_user_by_api_token(token: str): - return users_helper.get_user_by_api_token(token) - @staticmethod def get_user_lang_by_id(user_id): return users_helper.get_user_lang_by_id(user_id) @@ -43,26 +29,38 @@ class Users_Controller: def get_user_by_id(user_id): return users_helper.get_user(user_id) + @staticmethod + def update_server_order(user_id, user_server_order): + users_helper.update_server_order(user_id, user_server_order) + + @staticmethod + def get_server_order(user_id): + return users_helper.get_server_order(user_id) + @staticmethod def user_query(user_id): return users_helper.user_query(user_id) @staticmethod - def update_user(user_id, user_data={}, user_crafty_data={}): + def set_support_path(user_id, support_path): + users_helper.set_support_path(user_id, support_path) + + @staticmethod + def update_user(user_id: str, user_data=None, user_crafty_data=None): + if user_crafty_data is None: + user_crafty_data = {} + if user_data is None: + user_data = {} base_data = users_helper.get_user(user_id) up_data = {} added_roles = set() removed_roles = set() - removed_servers = set() for key in user_data: if key == "user_id": continue elif key == "roles": added_roles = user_data['roles'].difference(base_data['roles']) removed_roles = base_data['roles'].difference(user_data['roles']) - elif key == "regen_api": - if user_data['regen_api']: - up_data['api_token'] = management_helper.new_api_token() elif key == "password": if user_data['password'] is not None and user_data['password'] != "": up_data['password'] = helper.encode_pass(user_data['password']) @@ -70,16 +68,15 @@ class Users_Controller: up_data[key] = user_data[key] up_data['last_update'] = helper.get_time_as_string() up_data['lang'] = user_data['lang'] - logger.debug("user: {} +role:{} -role:{}".format(user_data, added_roles, removed_roles)) + logger.debug(f"user: {user_data} +role:{added_roles} -role:{removed_roles}") for role in added_roles: users_helper.get_or_create(user_id=user_id, role_id=role) - # TODO: This is horribly inefficient and we should be using bulk queries but im going for functionality at this point + permissions_mask = user_crafty_data.get('permissions_mask', '000') + + if 'server_quantity' in user_crafty_data: + limit_server_creation = user_crafty_data['server_quantity'][ + Enum_Permissions_Crafty.Server_Creation.name] - for key in user_crafty_data: - if key == "permissions_mask": - permissions_mask = user_crafty_data['permissions_mask'] - if key == "server_quantity": - limit_server_creation = user_crafty_data['server_quantity'][Enum_Permissions_Crafty.Server_Creation.name] limit_user_creation = user_crafty_data['server_quantity'][Enum_Permissions_Crafty.User_Config.name] limit_role_creation = user_crafty_data['server_quantity'][Enum_Permissions_Crafty.Roles_Config.name] else: @@ -87,15 +84,20 @@ class Users_Controller: limit_user_creation = 0 limit_role_creation = 0 - crafty_permissions.add_or_update_user(user_id, permissions_mask, limit_server_creation, limit_user_creation, limit_role_creation) + crafty_permissions.add_or_update_user( + user_id, + permissions_mask, + limit_server_creation, + limit_user_creation, + limit_role_creation) - users_helper.delete_user_roles(user_id, removed_roles) + users_helper.delete_user_roles(user_id, removed_roles) users_helper.update_user(user_id, up_data) @staticmethod - def add_user(username, password=None, api_token=None, enabled=True, superuser=False): - return users_helper.add_user(username, password=password, api_token=api_token, enabled=enabled, superuser=superuser) + def add_user(username, password=None, email="default@example.com", enabled: bool = True, superuser: bool = False): + return users_helper.add_user(username, password=password, email=email, enabled=enabled, superuser=superuser) @staticmethod def remove_user(user_id): @@ -105,10 +107,20 @@ class Users_Controller: def user_id_exists(user_id): return users_helper.user_id_exists(user_id) - #************************************************************************************************ + @staticmethod + def get_user_id_by_api_token(token: str) -> str: + token_data = authentication.check_no_iat(token) + return token_data['user_id'] + + @staticmethod + def get_user_by_api_token(token: str): + _, user = authentication.check(token) + return user + + # ************************************************************************************************ # User Roles Methods - #************************************************************************************************ - + # ************************************************************************************************ + @staticmethod def get_user_roles_id(user_id): return users_helper.get_user_roles_id(user_id) @@ -124,7 +136,33 @@ class Users_Controller: @staticmethod def add_user_roles(user): return users_helper.add_user_roles(user) - + @staticmethod def user_role_query(user_id): return users_helper.user_role_query(user_id) + + # ************************************************************************************************ + # Api Keys Methods + # ************************************************************************************************ + + @staticmethod + def get_user_api_keys(user_id: str): + return users_helper.get_user_api_keys(user_id) + + @staticmethod + def get_user_api_key(key_id: str): + return users_helper.get_user_api_key(key_id) + + @staticmethod + def add_user_api_key(name: str, user_id: str, superuser: bool = False, + server_permissions_mask: Optional[str] = None, + crafty_permissions_mask: Optional[str] = None): + return users_helper.add_user_api_key(name, user_id, superuser, server_permissions_mask, crafty_permissions_mask) + + @staticmethod + def delete_user_api_keys(user_id: str): + return users_helper.delete_user_api_keys(user_id) + + @staticmethod + def delete_user_api_key(key_id: str): + return users_helper.delete_user_api_key(key_id) diff --git a/app/classes/minecraft/bedrock_ping.py b/app/classes/minecraft/bedrock_ping.py new file mode 100644 index 00000000..1d2a8c99 --- /dev/null +++ b/app/classes/minecraft/bedrock_ping.py @@ -0,0 +1,107 @@ +import os +import socket +import time +import psutil + +class BedrockPing: + magic = b'\x00\xff\xff\x00\xfe\xfe\xfe\xfe\xfd\xfd\xfd\xfd\x12\x34\x56\x78' + fields = { # (len, signed) + "byte": (1, False), + "long": (8, True), + "ulong": (8, False), + "magic": (16, False), + "short": (2, True), + "ushort": (2, False), #unsigned short + "string": (2, False), #strlen is ushort + "bool": (1, False), + "address": (7, False), + "uint24le": (3, False) + } + byte_order = 'big' + + def __init__(self, bedrock_addr, bedrock_port, client_guid=0, timeout=5): + self.addr = bedrock_addr + self.port = bedrock_port + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.sock.settimeout(timeout) + self.proc = psutil.Process(os.getpid()) + self.guid = client_guid + self.guid_bytes = self.guid.to_bytes(8, BedrockPing.byte_order) + + @staticmethod + def __byter(in_val, to_type): + f = BedrockPing.fields[to_type] + return in_val.to_bytes(f[0], BedrockPing.byte_order, signed=f[1]) + + @staticmethod + def __slice(in_bytes, pattern): + ret = [] + bi = 0 # bytes index + pi = 0 # pattern index + while bi < len(in_bytes): + try: + f = BedrockPing.fields[pattern[pi]] + except IndexError as index_error: + raise IndexError("Ran out of pattern with additional bytes remaining") from index_error + if pattern[pi] == "string": + shl = f[0] # string header length + sl = int.from_bytes(in_bytes[bi:bi+shl], BedrockPing.byte_order, signed=f[1]) # string length + l = shl+sl + ret.append(in_bytes[bi+shl:bi+shl+sl].decode('ascii')) + elif pattern[pi] == "magic": + l = f[0] # length of field + ret.append(in_bytes[bi:bi+l]) + else: + l = f[0] # length of field + ret.append(int.from_bytes(in_bytes[bi:bi+l], BedrockPing.byte_order, signed=f[1])) + bi+=l + pi+=1 + return ret + + @staticmethod + def __get_time(): + #return time.time_ns() // 1000000 + return time.perf_counter_ns() // 1000000 + + def __sendping(self): + pack_id = BedrockPing.__byter(0x01, 'byte') + now = BedrockPing.__byter(BedrockPing.__get_time(), 'ulong') + guid = self.guid_bytes + d2s = pack_id+now+BedrockPing.magic+guid + #print("S:", d2s) + self.sock.sendto(d2s, (self.addr, self.port)) + + def __recvpong(self): + data = self.sock.recv(4096) + if data[0] == 0x1c: + ret = {} + sliced = BedrockPing.__slice(data,["byte","ulong","ulong","magic","string"]) + if sliced[3] != BedrockPing.magic: + raise ValueError(f"Incorrect magic received ({sliced[3]})") + ret["server_guid"] = sliced[2] + ret["server_string_raw"] = sliced[4] + server_info = sliced[4].split(';') + ret["server_edition"] = server_info[0] + ret["server_motd"] = (server_info[1], server_info[7]) + ret["server_protocol_version"] = server_info[2] + ret["server_version_name"] = server_info[3] + ret["server_player_count"] = server_info[4] + ret["server_player_max"] = server_info[5] + ret["server_uuid"] = server_info[6] + ret["server_game_mode"] = server_info[8] + ret["server_game_mode_num"] = server_info[9] + ret["server_port_ipv4"] = server_info[10] + ret["server_port_ipv6"] = server_info[11] + return ret + else: + raise ValueError(f"Incorrect packet type ({data[0]} detected") + + def ping(self, retries=3): + rtr = retries + while rtr > 0: + try: + self.__sendping() + return self.__recvpong() + except ValueError as e: + print(f"E: {e}, checking next packet. Retries remaining: {rtr}/{retries}") + rtr -= 1 diff --git a/app/classes/minecraft/mc_ping.py b/app/classes/minecraft/mc_ping.py index 0d21d9d4..1e4e6b7e 100644 --- a/app/classes/minecraft/mc_ping.py +++ b/app/classes/minecraft/mc_ping.py @@ -1,16 +1,18 @@ -from app.classes.shared.helpers import Helpers import struct import socket import base64 import json -import sys import os +import re import logging.config +import uuid +import random + +from app.classes.minecraft.bedrock_ping import BedrockPing from app.classes.shared.console import console logger = logging.getLogger(__name__) - class Server: def __init__(self, data): self.description = data.get('description') @@ -41,7 +43,7 @@ class Server: if "obfuscated" in e.keys(): lines.append(get_code_format("obfuscated")) if "color" in e.keys(): - lines.append(get_code_format(e['color'])) + lines.append(get_code_format(e['color'])) #Then append the text if "text" in e.keys(): if e['text'] == '\n': @@ -57,7 +59,11 @@ class Server: self.description = self.description['text'] self.icon = base64.b64decode(data.get('favicon', '')[22:]) - self.players = Players(data['players']).report() + try: + self.players = Players(data['players']).report() + except KeyError: + logger.error("Error geting player information key error") + self.players = [] self.version = data['version']['name'] self.protocol = data['version']['protocol'] @@ -101,13 +107,13 @@ def get_code_format(format_name): if format_name in data.keys(): return data.get(format_name) else: - logger.error("Format MOTD Error: format name {} does not exist".format(format_name)) - console.error("Format MOTD Error: format name {} does not exist".format(format_name)) + logger.error(f"Format MOTD Error: format name {format_name} does not exist") + console.error(f"Format MOTD Error: format name {format_name} does not exist") return "" except Exception as e: - logger.critical("Config File Error: Unable to read {} due to {}".format(format_file, e)) - console.critical("Config File Error: Unable to read {} due to {}".format(format_file, e)) + logger.critical(f"Config File Error: Unable to read {format_file} due to {e}") + console.critical(f"Config File Error: Unable to read {format_file} due to {e}") return "" @@ -126,15 +132,14 @@ def ping(ip, port): j += 1 if j > 5: raise ValueError('var_int too big') - if not (k & 0x80): + if not k & 0x80: return i sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: sock.connect((ip, port)) - except socket.error as err: - pass + except socket.error: return False try: @@ -163,7 +168,25 @@ def ping(ip, port): return False data += chunk - logger.debug("Server reports this data on ping: {}".format(data)) - return Server(json.loads(data)) + logger.debug(f"Server reports this data on ping: {data}") + try: + return Server(json.loads(data)) + except KeyError: + return {} finally: sock.close() + +# For the rest of requests see wiki.vg/Protocol +def ping_bedrock(ip, port): + rd = random.Random() + try: + #pylint: disable=consider-using-f-string + rd.seed(''.join(re.findall('..', '%012x' % uuid.getnode()))) + client_guid = uuid.UUID(int=rd.getrandbits(32)).int + except: + client_guid = 0 + try: + brp = BedrockPing(ip, port, client_guid) + return brp.ping() + except: + logger.debug("Unable to get RakNet stats") diff --git a/app/classes/minecraft/server_props.py b/app/classes/minecraft/server_props.py index 89c24c99..027e11ac 100644 --- a/app/classes/minecraft/server_props.py +++ b/app/classes/minecraft/server_props.py @@ -1,66 +1,66 @@ -import pprint -import os - -class ServerProps: - - def __init__(self, filepath): - self.filepath = filepath - self.props = self._parse() - - def _parse(self): - """Loads and parses the file speified in self.filepath""" - with open(self.filepath) as fp: - line = fp.readline() - d = {} - if os.path.exists(".header"): - os.remove(".header") - while line: - if '#' != line[0]: - s = line - s1 = s[:s.find('=')] - if '\n' in s: - s2 = s[s.find('=')+1:s.find('\n')] - else: - s2 = s[s.find('=')+1:] - d[s1] = s2 - else: - with open(".header", "a+") as h: - h.write(line) - line = fp.readline() - return d - - def print(self): - """Prints the properties dictionary (using pprint)""" - pprint.pprint(self.props) - - def get(self): - """Returns the properties dictionary""" - return self.props - - def update(self, key, val): - """Updates property in the properties dictionary [ update("pvp", "true") ] and returns boolean condition""" - if key in self.props.keys(): - self.props[key] = val - return True - else: - return False - - def save(self): - """Writes to the new file""" - with open(self.filepath, "a+") as f: - f.truncate(0) - with open(".header") as header: - line = header.readline() - while line: - f.write(line) - line = header.readline() - header.close() - for key, value in self.props.items(): - f.write(key + "=" + value + "\n") - if os.path.exists(".header"): - os.remove(".header") - - @staticmethod - def cleanup(): - if os.path.exists(".header"): - os.remove(".header") +import pprint +import os + +class ServerProps: + + def __init__(self, filepath): + self.filepath = filepath + self.props = self._parse() + + def _parse(self): + """Loads and parses the file specified in self.filepath""" + with open(self.filepath, encoding='utf-8') as fp: + line = fp.readline() + d = {} + if os.path.exists(".header"): + os.remove(".header") + while line: + if '#' != line[0]: + s = line + s1 = s[:s.find('=')] + if '\n' in s: + s2 = s[s.find('=')+1:s.find('\n')] + else: + s2 = s[s.find('=')+1:] + d[s1] = s2 + else: + with open(".header", "a+", encoding='utf-8') as h: + h.write(line) + line = fp.readline() + return d + + def print(self): + """Prints the properties dictionary (using pprint)""" + pprint.pprint(self.props) + + def get(self): + """Returns the properties dictionary""" + return self.props + + def update(self, key, val): + """Updates property in the properties dictionary [ update("pvp", "true") ] and returns boolean condition""" + if key in self.props.keys(): + self.props[key] = val + return True + else: + return False + + def save(self): + """Writes to the new file""" + with open(self.filepath, "a+", encoding='utf-8') as f: + f.truncate(0) + with open(".header", encoding='utf-8') as header: + line = header.readline() + while line: + f.write(line) + line = header.readline() + header.close() + for key, value in self.props.items(): + f.write(key + "=" + value + "\n") + if os.path.exists(".header"): + os.remove(".header") + + @staticmethod + def cleanup(): + if os.path.exists(".header"): + os.remove(".header") diff --git a/app/classes/minecraft/serverjars.py b/app/classes/minecraft/serverjars.py index c0b9c8d5..46e040a5 100644 --- a/app/classes/minecraft/serverjars.py +++ b/app/classes/minecraft/serverjars.py @@ -1,5 +1,3 @@ -import os -import sys import json import threading import time @@ -7,10 +5,9 @@ import shutil import logging from datetime import datetime +from app.classes.controllers.servers_controller import Servers_Controller +from app.classes.models.server_permissions import server_permissions from app.classes.shared.helpers import helper -from app.classes.shared.console import console -from app.classes.models.servers import Servers -from app.classes.minecraft.server_props import ServerProps from app.classes.web.websocket_helper import websocket_helper logger = logging.getLogger(__name__) @@ -18,11 +15,8 @@ logger = logging.getLogger(__name__) try: import requests -except ModuleNotFoundError as e: - logger.critical("Import Error: Unable to load {} module".format(e.name), exc_info=True) - console.critical("Import Error: Unable to load {} module".format(e.name)) - sys.exit(1) - +except ModuleNotFoundError as err: + helper.auto_installer_fix(err) class ServerJars: @@ -30,7 +24,7 @@ class ServerJars: self.base_url = "https://serverjars.com" def _get_api_result(self, call_url: str): - full_url = "{base}{call_url}".format(base=self.base_url, call_url=call_url) + full_url = f"{self.base_url}{call_url}" try: r = requests.get(full_url, timeout=2) @@ -38,20 +32,20 @@ class ServerJars: if r.status_code not in [200, 201]: return {} except Exception as e: - logger.error("Unable to connect to serverjar.com api due to error: {}".format(e)) + logger.error(f"Unable to connect to serverjar.com api due to error: {e}") return {} try: api_data = json.loads(r.content) except Exception as e: - logger.error("Unable to parse serverjar.com api result due to error: {}".format(e)) + logger.error(f"Unable to parse serverjar.com api result due to error: {e}") return {} api_result = api_data.get('status') api_response = api_data.get('response', {}) if api_result != "success": - logger.error("Api returned a failed status: {}".format(api_result)) + logger.error(f"Api returned a failed status: {api_result}") return {} return api_response @@ -61,11 +55,11 @@ class ServerJars: cache_file = helper.serverjar_cache cache = {} try: - with open(cache_file, "r") as f: + with open(cache_file, "r", encoding='utf-8') as f: cache = json.load(f) except Exception as e: - logger.error("Unable to read serverjars.com cache file: {}".format(e)) + logger.error(f"Unable to read serverjars.com cache file: {e}") return cache @@ -99,7 +93,7 @@ class ServerJars: def _check_api_alive(self): logger.info("Checking serverjars.com API status") - check_url = "{base}/api/fetchTypes".format(base=self.base_url) + check_url = f"{self.base_url}/api/fetchTypes" try: r = requests.get(check_url, timeout=2) @@ -107,7 +101,7 @@ class ServerJars: logger.info("Serverjars.com API is alive") return True except Exception as e: - logger.error("Unable to connect to serverjar.com api due to error: {}".format(e)) + logger.error(f"Unable to connect to serverjar.com api due to error: {e}") return {} logger.error("unable to contact serverjars.com api") @@ -153,15 +147,15 @@ class ServerJars: # save our cache try: - with open(cache_file, "w") as f: + with open(cache_file, "w", encoding='utf-8') as f: f.write(json.dumps(data, indent=4)) logger.info("Cache file refreshed") except Exception as e: - logger.error("Unable to update serverjars.com cache file: {}".format(e)) + logger.error(f"Unable to update serverjars.com cache file: {e}") def _get_jar_details(self, jar_type='servers'): - url = '/api/fetchAll/{type}'.format(type=jar_type) + url = f'/api/fetchAll/{jar_type}' response = self._get_api_result(url) temp = [] for v in response: @@ -174,26 +168,53 @@ class ServerJars: response = self._get_api_result(url) return response - def download_jar(self, server, version, path, name): - update_thread = threading.Thread(target=self.a_download_jar, daemon=True, name="exe_download", args=(server, version, path, name)) + def download_jar(self, server, version, path, server_id): + update_thread = threading.Thread(target=self.a_download_jar, daemon=True, args=(server, version, path, server_id)) update_thread.start() - def a_download_jar(self, server, version, path, name): - fetch_url = "{base}/api/fetchJar/{server}/{version}".format(base=self.base_url, server=server, version=version) + def a_download_jar(self, server, version, path, server_id): + #delaying download for server register to finish + time.sleep(3) + fetch_url = f"{self.base_url}/api/fetchJar/{server}/{version}" + server_users = server_permissions.get_server_user_list(server_id) + + + #We need to make sure the server is registered before we submit a db update for it's stats. + while True: + try: + Servers_Controller.set_download(server_id) + for user in server_users: + websocket_helper.broadcast_user(user, 'send_start_reload', { + }) + + break + except: + logger.debug("server not registered yet. Delaying download.") # open a file stream with requests.get(fetch_url, timeout=2, stream=True) as r: try: with open(path, 'wb') as output: shutil.copyfileobj(r.raw, output) + Servers_Controller.finish_download(server_id) + for user in server_users: + websocket_helper.broadcast_user(user, 'notification', "Executable download finished") + time.sleep(3) + websocket_helper.broadcast_user(user, 'send_start_reload', { + }) + return True except Exception as e: - logger.error("Unable to save jar to {path} due to error:{error}".format(path=path, error=e)) - pass - websocket_helper.broadcast('notification', "Executable download finished for server named: " + name) - + logger.error(f"Unable to save jar to {path} due to error:{e}") + Servers_Controller.finish_download(server_id) + server_users = server_permissions.get_server_user_list(server_id) + for user in server_users: + websocket_helper.broadcast_user(user, 'notification', "Executable download finished") + time.sleep(3) + websocket_helper.broadcast_user(user, 'send_start_reload', { + }) - return False + return False server_jar_obj = ServerJars() diff --git a/app/classes/minecraft/stats.py b/app/classes/minecraft/stats.py index 4b9c07fa..456213ce 100644 --- a/app/classes/minecraft/stats.py +++ b/app/classes/minecraft/stats.py @@ -1,20 +1,16 @@ -import os import json -import time -import psutil import logging import datetime import base64 +import psutil - -from app.classes.shared.helpers import helper from app.classes.minecraft.mc_ping import ping from app.classes.models.management import Host_Stats -from app.classes.models.servers import Server_Stats, servers_helper +from app.classes.models.servers import servers_helper +from app.classes.shared.helpers import helper logger = logging.getLogger(__name__) - class Stats: def __init__(self, controller): @@ -38,8 +34,8 @@ class Stats: 'mem_total': helper.human_readable_file_size(psutil.virtual_memory()[0]), 'disk_data': self._all_disk_usage() } - server_stats = self.get_servers_stats() - data['servers'] = server_stats + #server_stats = self.get_servers_stats() + #data['servers'] = server_stats data['node_stats'] = node_stats return data @@ -64,8 +60,6 @@ class Stats: real_cpu = round(p.cpu_percent(interval=0.5) / psutil.cpu_count(), 2) - process_start_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(p.create_time())) - # this is a faster way of getting data for a process with p.oneshot(): process_stats = { @@ -76,7 +70,7 @@ class Stats: return process_stats except Exception as e: - logger.error("Unable to get process details for pid: {} due to error: {}".format(process_pid, e)) + logger.error(f"Unable to get process details for pid: {process_pid} due to error: {e}") # Dummy Data process_stats = { @@ -92,7 +86,7 @@ class Stats: # print(templ % ("Device", "Total", "Used", "Free", "Use ", "Type","Mount")) for part in psutil.disk_partitions(all=False): - if os.name == 'nt': + if helper.is_os_windows(): if 'cdrom' in part.opts or part.fstype == '': # skip cd-rom drives with no disk in it; they may raise # ENOENT, pop-up a Windows GUI error for a non-ready @@ -114,28 +108,44 @@ class Stats: return disk_data @staticmethod - def get_world_size(world_path): + def get_world_size(server_path): total_size = 0 - # do a scan of the directories in the server path. - for root, dirs, files in os.walk(world_path, topdown=False): - - # for each directory we find - for name in dirs: - - # if the directory name is "region" - if name == "region": - # log it! - logger.debug("Path %s is called region. Getting directory size", os.path.join(root, name)) - - # get this directory size, and add it to the total we have running. - total_size += helper.get_dir_size(os.path.join(root, name)) + total_size = helper.get_dir_size(server_path) level_total_size = helper.human_readable_file_size(total_size) return level_total_size + def get_server_players(self, server_id): + + server = servers_helper.get_server_data_by_id(server_id) + + logger.info(f"Getting players for server {server}") + + # get our settings and data dictionaries + # server_settings = server.get('server_settings', {}) + # server_data = server.get('server_data_obj', {}) + + + # TODO: search server properties file for possible override of 127.0.0.1 + internal_ip = server['server_ip'] + server_port = server['server_port'] + + logger.debug("Pinging {internal_ip} on port {server_port}") + if servers_helper.get_server_type_by_id(server_id) != 'minecraft-bedrock': + int_mc_ping = ping(internal_ip, int(server_port)) + + + ping_data = {} + + # if we got a good ping return, let's parse it + if int_mc_ping: + ping_data = Stats.parse_server_ping(int_mc_ping) + return ping_data['players'] + return [] + @staticmethod def parse_server_ping(ping_obj: object): online_stats = {} @@ -144,14 +154,15 @@ class Stats: online_stats = json.loads(ping_obj.players) except Exception as e: - logger.info("Unable to read json from ping_obj: {}".format(e)) - pass + logger.info(f"Unable to read json from ping_obj: {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("Unable to read the server icon : {}".format(e)) + logger.info(f"Unable to read the server icon : {e}") ping_data = { 'online': online_stats.get("online", 0), @@ -163,159 +174,27 @@ class Stats: } return ping_data - - def get_server_players(self, server_id): - server = servers_helper.get_server_data_by_id(server_id) + @staticmethod + def parse_server_RakNet_ping(ping_obj: object): - logger.info("Getting players for server {}".format(server)) - - # get our settings and data dictionaries - server_settings = server.get('server_settings', {}) - server_data = server.get('server_data_obj', {}) - - - # TODO: search server properties file for possible override of 127.0.0.1 - internal_ip = server['server_ip'] - server_port = server['server_port'] - - logger.debug("Pinging {} on port {}".format(internal_ip, server_port)) - int_mc_ping = ping(internal_ip, int(server_port)) - - ping_data = {} - - # if we got a good ping return, let's parse it - if int_mc_ping: - ping_data = self.parse_server_ping(int_mc_ping) - return ping_data['players'] - return [] - - def get_servers_stats(self): - - server_stats_list = [] - server_stats = {} - - servers = self.controller.servers_list - - logger.info("Getting Stats for all servers...") - - for s in servers: - - server_id = s.get('server_id', None) - server = servers_helper.get_server_data_by_id(server_id) - - - logger.debug('Getting stats for server: {}'.format(server_id)) - - # get our server object, settings and data dictionaries - server_obj = s.get('server_obj', None) - server_obj.reload_server_settings() - server_settings = s.get('server_settings', {}) - server_data = self.controller.get_server_data(server_id) - - # world data - world_name = server_settings.get('level-name', 'Unknown') - world_path = os.path.join(server_data.get('path', None), world_name) - - # process stats - p_stats = self._get_process_stats(server_obj.process) - - # TODO: search server properties file for possible override of 127.0.0.1 - internal_ip = server['server_ip'] - server_port = server['server_port'] - - logger.debug("Pinging server '{}' on {}:{}".format(s.get('server_name', "ID#{}".format(server_id)), internal_ip, server_port)) - int_mc_ping = ping(internal_ip, int(server_port)) - - int_data = False - ping_data = {} - - # if we got a good ping return, let's parse it - if int_mc_ping: - int_data = True - ping_data = self.parse_server_ping(int_mc_ping) - - server_stats = { - 'id': server_id, - 'started': server_obj.get_start_time(), - 'running': server_obj.check_running(), - 'cpu': p_stats.get('cpu_usage', 0), - 'mem': p_stats.get('memory_usage', 0), - "mem_percent": p_stats.get('mem_percentage', 0), - 'world_name': world_name, - 'world_size': self.get_world_size(world_path), - 'server_port': server_port, - 'int_ping_results': int_data, - 'online': ping_data.get("online", False), - "max": ping_data.get("max", False), - 'players': ping_data.get("players", False), - 'desc': ping_data.get("server_description", False), - 'version': ping_data.get("server_version", False) - } - - # add this servers data to the stack - server_stats_list.append(server_stats) - - return server_stats_list - - def get_raw_server_stats(self, server_id): - - server_stats = {} - server = self.controller.get_server_obj(server_id) - - logger.debug('Getting stats for server: {}'.format(server_id)) - - # get our server object, settings and data dictionaries - server_obj = self.controller.get_server_obj(server_id) - server_obj.reload_server_settings() - server_settings = self.controller.get_server_settings(server_id) - server_data = self.controller.get_server_data(server_id) - - # world data - world_name = server_settings.get('level-name', 'Unknown') - world_path = os.path.join(server_data.get('path', None), world_name) - - # process stats - p_stats = self._get_process_stats(server_obj.process) - - # TODO: search server properties file for possible override of 127.0.0.1 - #internal_ip = server['server_ip'] - #server_port = server['server_port'] - internal_ip = server_data.get('server_ip', "127.0.0.1") - server_port = server_settings.get('server-port', "25565") - - - logger.debug("Pinging server '{}' on {}:{}".format(server.name, internal_ip, server_port)) - int_mc_ping = ping(internal_ip, int(server_port)) - - int_data = False - ping_data = {} - - # if we got a good ping return, let's parse it - if int_mc_ping: - int_data = True - ping_data = self.parse_server_ping(int_mc_ping) - - server_stats = { - 'id': server_id, - 'started': server_obj.get_start_time(), - 'running': server_obj.check_running(), - 'cpu': p_stats.get('cpu_usage', 0), - 'mem': p_stats.get('memory_usage', 0), - "mem_percent": p_stats.get('mem_percentage', 0), - 'world_name': world_name, - 'world_size': self.get_world_size(world_path), - 'server_port': server_port, - 'int_ping_results': int_data, - 'online': ping_data.get("online", False), - "max": ping_data.get("max", False), - 'players': ping_data.get("players", False), - 'desc': ping_data.get("server_description", False), - 'version': ping_data.get("server_version", False), - 'icon': ping_data.get("server_icon", False) + try: + server_icon = base64.encodebytes(ping_obj['icon']) + except Exception as e: + server_icon = False + logger.info(f"Unable to read the server icon : {e}") + ping_data = { + 'online': ping_obj['server_player_count'], + 'max': ping_obj['server_player_max'], + 'players': [], + 'server_description': ping_obj['server_edition'], + 'server_version': ping_obj['server_version_name'], + 'server_icon': server_icon } - return server_stats + + return ping_data + def record_stats(self): stats_to_send = self.get_node_stats() @@ -333,26 +212,26 @@ class Stats: Host_Stats.disk_json: node_stats.get('disk_data', '{}') }).execute() - server_stats = stats_to_send.get('servers') - - for server in server_stats: - Server_Stats.insert({ - Server_Stats.server_id: server.get('id', 0), - Server_Stats.started: server.get('started', ""), - Server_Stats.running: server.get('running', False), - Server_Stats.cpu: server.get('cpu', 0), - Server_Stats.mem: server.get('mem', 0), - Server_Stats.mem_percent: server.get('mem_percent', 0), - Server_Stats.world_name: server.get('world_name', ""), - Server_Stats.world_size: server.get('world_size', ""), - Server_Stats.server_port: server.get('server_port', ""), - Server_Stats.int_ping_results: server.get('int_ping_results', False), - Server_Stats.online: server.get("online", False), - Server_Stats.max: server.get("max", False), - Server_Stats.players: server.get("players", False), - Server_Stats.desc: server.get("desc", False), - Server_Stats.version: server.get("version", False) - }).execute() +# server_stats = stats_to_send.get('servers')# +# +# for server in server_stats: +# Server_Stats.insert({ +# Server_Stats.server_id: server.get('id', 0), +# Server_Stats.started: server.get('started', ""), +# Server_Stats.running: server.get('running', False), +# Server_Stats.cpu: server.get('cpu', 0), +# Server_Stats.mem: server.get('mem', 0), +# Server_Stats.mem_percent: server.get('mem_percent', 0), +# Server_Stats.world_name: server.get('world_name', ""), +# Server_Stats.world_size: server.get('world_size', ""), +# Server_Stats.server_port: server.get('server_port', ""), +# Server_Stats.int_ping_results: server.get('int_ping_results', False), +# Server_Stats.online: server.get("online", False), +# Server_Stats.max: server.get("max", False), +# Server_Stats.players: server.get("players", False), +# Server_Stats.desc: server.get("desc", False), +# Server_Stats.version: server.get("version", False) +# }).execute() # delete old data max_age = helper.get_setting("history_max_age") @@ -360,4 +239,4 @@ class Stats: last_week = now.day - max_age Host_Stats.delete().where(Host_Stats.time < last_week).execute() - Server_Stats.delete().where(Server_Stats.created < last_week).execute() \ No newline at end of file +# Server_Stats.delete().where(Server_Stats.created < last_week).execute() diff --git a/app/classes/models/crafty_permissions.py b/app/classes/models/crafty_permissions.py index dbd51232..13a655e5 100644 --- a/app/classes/models/crafty_permissions.py +++ b/app/classes/models/crafty_permissions.py @@ -1,28 +1,20 @@ -import os -import sys import logging -import datetime from app.classes.shared.helpers import helper -from app.classes.shared.console import console -from app.classes.models.users import Users +from app.classes.shared.permission_helper import permission_helper +from app.classes.models.users import Users, ApiKeys + +try: + from peewee import SqliteDatabase, Model, ForeignKeyField, CharField, IntegerField, DoesNotExist + from enum import Enum + +except ModuleNotFoundError as e: + helper.auto_installer_fix(e) logger = logging.getLogger(__name__) peewee_logger = logging.getLogger('peewee') peewee_logger.setLevel(logging.INFO) - -try: - from peewee import * - from playhouse.shortcuts import model_to_dict - from enum import Enum - import yaml - -except ModuleNotFoundError as e: - logger.critical("Import Error: Unable to load {} module".format(e.name), exc_info=True) - console.critical("Import Error: Unable to load {} module".format(e.name)) - sys.exit(1) - -database = SqliteDatabase(helper.db_path, pragmas={ +database = SqliteDatabase(helper.db_path, pragmas = { 'journal_mode': 'wal', 'cache_size': -1024 * 10}) @@ -123,7 +115,7 @@ class Permissions_Crafty: def get_User_Crafty(user_id): try: user_crafty = User_Crafty.select().where(User_Crafty.user_id == user_id).get() - except User_Crafty.DoesNotExist: + except DoesNotExist: user_crafty = User_Crafty.insert({ User_Crafty.user_id: user_id, User_Crafty.permissions: "000", @@ -172,7 +164,6 @@ class Permissions_Crafty: @staticmethod def get_crafty_limit_value(user_id, permission): - user_crafty = crafty_permissions.get_User_Crafty(user_id) quantity_list = crafty_permissions.get_permission_quantity_list(user_id) return quantity_list[permission] @@ -191,4 +182,18 @@ class Permissions_Crafty: User_Crafty.save(user_crafty) return user_crafty.created_server -crafty_permissions = Permissions_Crafty() \ No newline at end of file + @staticmethod + def get_api_key_permissions_list(key: ApiKeys): + user = key.user + if user.superuser and key.superuser: + return crafty_permissions.get_permissions_list() + else: + user_permissions_mask = crafty_permissions.get_crafty_permissions_mask(user.user_id) + key_permissions_mask: str = key.crafty_permissions + permissions_mask = permission_helper.combine_masks(user_permissions_mask, key_permissions_mask) + permissions_list = crafty_permissions.get_permissions(permissions_mask) + return permissions_list + + + +crafty_permissions = Permissions_Crafty() diff --git a/app/classes/models/management.py b/app/classes/models/management.py index 4e2fedef..927f97b4 100644 --- a/app/classes/models/management.py +++ b/app/classes/models/management.py @@ -1,32 +1,24 @@ -import os -import sys import logging import datetime -from app.classes.shared.helpers import helper -from app.classes.shared.console import console -from app.classes.shared.main_models import db_helper from app.classes.models.users import Users, users_helper -from app.classes.models.servers import Servers, servers_helper +from app.classes.models.servers import Servers +from app.classes.models.server_permissions import server_permissions +from app.classes.shared.helpers import helper +from app.classes.shared.main_models import db_helper from app.classes.web.websocket_helper import websocket_helper +try: + from peewee import SqliteDatabase, Model, ForeignKeyField, CharField, IntegerField, DateTimeField, FloatField, TextField, AutoField, BooleanField + from playhouse.shortcuts import model_to_dict + +except ModuleNotFoundError as e: + helper.auto_installer_fix(e) logger = logging.getLogger(__name__) peewee_logger = logging.getLogger('peewee') peewee_logger.setLevel(logging.INFO) - -try: - from peewee import * - from playhouse.shortcuts import model_to_dict - from enum import Enum - import yaml - -except ModuleNotFoundError as e: - logger.critical("Import Error: Unable to load {} module".format(e.name), exc_info=True) - console.critical("Import Error: Unable to load {} module".format(e.name)) - sys.exit(1) - -database = SqliteDatabase(helper.db_path, pragmas={ +database = SqliteDatabase(helper.db_path, pragmas = { 'journal_mode': 'wal', 'cache_size': -1024 * 10}) @@ -112,6 +104,10 @@ class Schedules(Model): start_time = CharField(null=True) command = CharField(null=True) comment = CharField() + one_time = BooleanField(default=False) + cron_string = CharField(default="") + parent = IntegerField(null=True) + delay = IntegerField(default=0) class Meta: table_name = 'schedules' @@ -122,22 +118,22 @@ class Schedules(Model): # Backups Class #************************************************************************************************ class Backups(Model): - directories = CharField(null=True) + excluded_dirs = CharField(null=True) max_backups = IntegerField() server_id = ForeignKeyField(Servers, backref='backups_server') - schedule_id = ForeignKeyField(Schedules, backref='backups_schedule') - + compress = BooleanField(default=False) class Meta: table_name = 'backups' database = database class helpers_management: - + #************************************************************************************************ # Host_Stats Methods #************************************************************************************************ @staticmethod def get_latest_hosts_stats(): + #pylint: disable=no-member query = Host_Stats.select().order_by(Host_Stats.id.desc()).get() return model_to_dict(query) @@ -156,16 +152,16 @@ class helpers_management: @staticmethod def get_unactioned_commands(): query = Commands.select().where(Commands.executed == 0) - return db_helper.return_rows(query) + return query @staticmethod def mark_command_complete(command_id=None): if command_id is not None: - logger.debug("Marking Command {} completed".format(command_id)) + logger.debug(f"Marking Command {command_id} completed") Commands.update({ Commands.executed: True }).where(Commands.command_id == command_id).execute() - + #************************************************************************************************ # Audit_Log Methods #************************************************************************************************ @@ -173,15 +169,17 @@ class helpers_management: def get_actity_log(): q = Audit_Log.select() return db_helper.return_db_rows(q) - + @staticmethod def add_to_audit_log(user_id, log_msg, server_id=None, source_ip=None): - logger.debug("Adding to audit log User:{} - Message: {} ".format(user_id, log_msg)) + logger.debug(f"Adding to audit log User:{user_id} - Message: {log_msg} ") user_data = users_helper.get_user(user_id) - audit_msg = "{} {}".format(str(user_data['username']).capitalize(), log_msg) + audit_msg = f"{str(user_data['username']).capitalize()} {log_msg}" - websocket_helper.broadcast('notification', audit_msg) + server_users = server_permissions.get_server_user_list(server_id) + for user in server_users: + websocket_helper.broadcast_user(user,'notification', audit_msg) Audit_Log.insert({ Audit_Log.user_name: user_data['username'], @@ -190,6 +188,17 @@ class helpers_management: Audit_Log.log_msg: audit_msg, Audit_Log.source_ip: source_ip }).execute() + #deletes records when they're more than 100 + ordered = Audit_Log.select().order_by(+Audit_Log.created) + for item in ordered: + if not helper.get_setting('max_audit_entries'): + max_entries = 300 + else: + max_entries = helper.get_setting('max_audit_entries') + if Audit_Log.select().count() > max_entries: + Audit_Log.delete().where(Audit_Log.audit_id == item.audit_id).execute() + else: + return @staticmethod def add_to_audit_log_raw(user_name, user_id, server_id, log_msg, source_ip): @@ -200,12 +209,35 @@ class helpers_management: Audit_Log.log_msg: log_msg, Audit_Log.source_ip: source_ip }).execute() - + #deletes records when they're more than 100 + ordered = Audit_Log.select().order_by(+Audit_Log.created) + for item in ordered: + #configurable through app/config/config.json + if not helper.get_setting('max_audit_entries'): + max_entries = 300 + else: + max_entries = helper.get_setting('max_audit_entries') + if Audit_Log.select().count() > max_entries: + Audit_Log.delete().where(Audit_Log.audit_id == item.audit_id).execute() + else: + return #************************************************************************************************ # Schedules Methods #************************************************************************************************ @staticmethod - def create_scheduled_task(server_id, action, interval, interval_type, start_time, command, comment=None, enabled=True): + def create_scheduled_task( + server_id, + action, + interval, + interval_type, + start_time, + command, + comment=None, + enabled=True, + one_time=False, + cron_string='* * * * *', + parent=None, + delay=0): sch_id = Schedules.insert({ Schedules.server_id: server_id, Schedules.action: action, @@ -214,7 +246,12 @@ class helpers_management: Schedules.interval_type: interval_type, Schedules.start_time: start_time, Schedules.command: command, - Schedules.comment: comment + Schedules.comment: comment, + Schedules.one_time: one_time, + Schedules.cron_string: cron_string, + Schedules.parent: parent, + Schedules.delay: delay + }).execute() return sch_id @@ -227,20 +264,37 @@ class helpers_management: def update_scheduled_task(schedule_id, updates): Schedules.update(updates).where(Schedules.schedule_id == schedule_id).execute() + @staticmethod + def delete_scheduled_task_by_server(server_id): + Schedules.delete().where(Schedules.server_id == int(server_id)).execute() + @staticmethod def get_scheduled_task(schedule_id): - return model_to_dict(Schedules.get(Schedules.schedule_id == schedule_id)).execute() + return model_to_dict(Schedules.get(Schedules.schedule_id == schedule_id)) + + @staticmethod + def get_scheduled_task_model(schedule_id): + return Schedules.select().where(Schedules.schedule_id == schedule_id).get() @staticmethod def get_schedules_by_server(server_id): return Schedules.select().where(Schedules.server_id == server_id).execute() + @staticmethod + def get_child_schedules_by_server(schedule_id, server_id): + return Schedules.select().where(Schedules.server_id == server_id, Schedules.parent == schedule_id).execute() + + @staticmethod + def get_child_schedules(schedule_id): + return Schedules.select().where(Schedules.parent == schedule_id) + @staticmethod def get_schedules_all(): return Schedules.select().execute() @staticmethod def get_schedules_enabled(): + #pylint: disable=singleton-comparison return Schedules.select().where(Schedules.enabled == True).execute() #************************************************************************************************ @@ -249,51 +303,44 @@ class helpers_management: @staticmethod def get_backup_config(server_id): try: - row = Backups.select().where(Backups.server_id == server_id).join(Schedules).join(Servers)[0] + row = Backups.select().where(Backups.server_id == server_id).join(Servers)[0] conf = { "backup_path": row.server_id.backup_path, - "directories": row.directories, + "excluded_dirs": row.excluded_dirs, "max_backups": row.max_backups, - "auto_enabled": row.schedule_id.enabled, - "server_id": row.server_id.server_id + "server_id": row.server_id.server_id, + "compress": row.compress } except IndexError: conf = { "backup_path": None, - "directories": None, + "excluded_dirs": None, "max_backups": 0, - "auto_enabled": True, - "server_id": server_id + "server_id": server_id, + "compress": False, } return conf @staticmethod - def set_backup_config(server_id: int, backup_path: str = None, max_backups: int = None, auto_enabled: bool = True): - logger.debug("Updating server {} backup config with {}".format(server_id, locals())) - try: - row = Backups.select().where(Backups.server_id == server_id).join(Schedules).join(Servers)[0] + def set_backup_config(server_id: int, backup_path: str = None, max_backups: int = None, excluded_dirs: list = None, compress: bool = False): + logger.debug(f"Updating server {server_id} backup config with {locals()}") + if Backups.select().where(Backups.server_id == server_id).count() != 0: new_row = False conf = {} - schd = {} - except IndexError: + else: conf = { - "directories": None, + "excluded_dirs": None, "max_backups": 0, - "server_id": server_id - } - schd = { - "enabled": True, - "action": "backup_server", - "interval_type": "days", - "interval": 1, - "start_time": "00:00", - "server_id": server_id, - "comment": "Default backup job" + "server_id": server_id, + "compress": False } new_row = True if max_backups is not None: conf['max_backups'] = max_backups - schd['enabled'] = bool(auto_enabled) + if excluded_dirs is not None: + dirs_to_exclude = ",".join(excluded_dirs) + conf['excluded_dirs'] = dirs_to_exclude + conf['compress'] = compress if not new_row: with database.atomic(): if backup_path is not None: @@ -301,17 +348,47 @@ class helpers_management: else: u1 = 0 u2 = Backups.update(conf).where(Backups.server_id == server_id).execute() - u3 = Schedules.update(schd).where(Schedules.schedule_id == row.schedule_id).execute() - logger.debug("Updating existing backup record. {}+{}+{} rows affected".format(u1, u2, u3)) + logger.debug(f"Updating existing backup record. {u1}+{u2} rows affected") else: with database.atomic(): conf["server_id"] = server_id if backup_path is not None: - u = Servers.update(backup_path=backup_path).where(Servers.server_id == server_id) - s = Schedules.create(**schd) - conf['schedule_id'] = s.schedule_id - b = Backups.create(**conf) + Servers.update(backup_path=backup_path).where(Servers.server_id == server_id) + Backups.create(**conf) logger.debug("Creating new backup record.") + def get_excluded_backup_dirs(self, server_id: int): + excluded_dirs = self.get_backup_config(server_id)['excluded_dirs'] + if excluded_dirs is not None and excluded_dirs != "": + dir_list = excluded_dirs.split(",") + else: + dir_list = [] + return dir_list + + def add_excluded_backup_dir(self, server_id: int, dir_to_add: str): + dir_list = self.get_excluded_backup_dirs() + if dir_to_add not in dir_list: + dir_list.append(dir_to_add) + excluded_dirs = ",".join(dir_list) + self.set_backup_config(server_id=server_id, excluded_dirs=excluded_dirs) + else: + logger.debug(f"Not adding {dir_to_add} to excluded directories - already in the excluded directory list for server ID {server_id}") + + def del_excluded_backup_dir(self, server_id: int, dir_to_del: str): + dir_list = self.get_excluded_backup_dirs() + if dir_to_del in dir_list: + dir_list.remove(dir_to_del) + excluded_dirs = ",".join(dir_list) + self.set_backup_config(server_id=server_id, excluded_dirs=excluded_dirs) + else: + logger.debug(f"Not removing {dir_to_del} from excluded directories - not in the excluded directory list for server ID {server_id}") + + @staticmethod + def clear_unexecuted_commands(): + Commands.update({ + Commands.executed: True + #pylint: disable=singleton-comparison + }).where(Commands.executed == False).execute() + management_helper = helpers_management() diff --git a/app/classes/models/roles.py b/app/classes/models/roles.py index 49146b93..782e0af5 100644 --- a/app/classes/models/roles.py +++ b/app/classes/models/roles.py @@ -1,27 +1,19 @@ -import os -import sys import logging import datetime from app.classes.shared.helpers import helper -from app.classes.shared.console import console + +try: + from peewee import SqliteDatabase, Model, CharField, DoesNotExist, AutoField, DateTimeField + from playhouse.shortcuts import model_to_dict + +except ModuleNotFoundError as e: + helper.auto_installer_fix(e) logger = logging.getLogger(__name__) peewee_logger = logging.getLogger('peewee') peewee_logger.setLevel(logging.INFO) - -try: - from peewee import * - from playhouse.shortcuts import model_to_dict - from enum import Enum - import yaml - -except ModuleNotFoundError as e: - logger.critical("Import Error: Unable to load {} module".format(e.name), exc_info=True) - console.critical("Import Error: Unable to load {} module".format(e.name)) - sys.exit(1) - -database = SqliteDatabase(helper.db_path, pragmas={ +database = SqliteDatabase(helper.db_path, pragmas = { 'journal_mode': 'wal', 'cache_size': -1024 * 10}) @@ -45,7 +37,7 @@ class helper_roles: @staticmethod def get_all_roles(): query = Roles.select() - return query + return query @staticmethod def get_roleid_by_name(role_name): @@ -81,5 +73,5 @@ class helper_roles: if not roles_helper.get_role(role_id): return False return True - + roles_helper = helper_roles() diff --git a/app/classes/models/server_permissions.py b/app/classes/models/server_permissions.py index 43f50b96..9ec9c0cd 100644 --- a/app/classes/models/server_permissions.py +++ b/app/classes/models/server_permissions.py @@ -1,34 +1,25 @@ -import os -import sys import logging -import datetime -from app.classes.shared.helpers import helper -from app.classes.shared.console import console from app.classes.models.servers import Servers from app.classes.models.roles import Roles -from app.classes.models.users import users_helper +from app.classes.models.users import User_Roles, users_helper, ApiKeys, Users +from app.classes.shared.helpers import helper +from app.classes.shared.permission_helper import permission_helper + +try: + from peewee import SqliteDatabase, Model, ForeignKeyField, CharField, CompositeKey, JOIN + from enum import Enum + +except ModuleNotFoundError as e: + helper.auto_installer_fix(e) logger = logging.getLogger(__name__) peewee_logger = logging.getLogger('peewee') peewee_logger.setLevel(logging.INFO) - -try: - from peewee import * - from playhouse.shortcuts import model_to_dict - from enum import Enum - import yaml - -except ModuleNotFoundError as e: - logger.critical("Import Error: Unable to load {} module".format(e.name), exc_info=True) - console.critical("Import Error: Unable to load {} module".format(e.name)) - sys.exit(1) - -database = SqliteDatabase(helper.db_path, pragmas={ +database = SqliteDatabase(helper.db_path, pragmas = { 'journal_mode': 'wal', 'cache_size': -1024 * 10}) - #************************************************************************************************ # Role Servers Class #************************************************************************************************ @@ -78,10 +69,7 @@ class Permissions_Servers: @staticmethod def has_permission(permission_mask, permission_tested: Enum_Permissions_Server): - result = False - if permission_mask[permission_tested.value] == '1': - result = True - return result + return permission_mask[permission_tested.value] == '1' @staticmethod def set_permission(permission_mask, permission_tested: Enum_Permissions_Server, value): @@ -94,6 +82,14 @@ class Permissions_Servers: def get_permission(permission_mask, permission_tested: Enum_Permissions_Server): return permission_mask[permission_tested.value] + @staticmethod + def get_token_permissions(permissions_mask, api_permissions_mask): + permissions_list = [] + for member in Enum_Permissions_Server.__members__.items(): + if permission_helper.both_have_perm(permissions_mask, api_permissions_mask, member[1]): + permissions_list.append(member[1]) + return permissions_list + #************************************************************************************************ # Role_Servers Methods @@ -112,20 +108,29 @@ class Permissions_Servers: @staticmethod def add_role_server(server_id, role_id, rs_permissions="00000000"): - servers = Role_Servers.insert({Role_Servers.server_id: server_id, Role_Servers.role_id: role_id, Role_Servers.permissions: rs_permissions}).execute() + servers = Role_Servers.insert({Role_Servers.server_id: server_id, Role_Servers.role_id: role_id, + Role_Servers.permissions: rs_permissions}).execute() return servers @staticmethod def get_permissions_mask(role_id, server_id): permissions_mask = '' - role_server = Role_Servers.select().where(Role_Servers.role_id == role_id).where(Role_Servers.server_id == server_id).execute() + role_server = Role_Servers.select().where(Role_Servers.role_id == role_id).where(Role_Servers.server_id == server_id).get() permissions_mask = role_server.permissions return permissions_mask + @staticmethod + def get_server_roles(server_id): + role_list = [] + roles = Role_Servers.select().where(Role_Servers.server_id == server_id).execute() + for role in roles: + role_list.append(role.role_id) + return role_list + @staticmethod def get_role_permissions_list(role_id): permissions_mask = '00000000' - role_server = Role_Servers.get_or_none(role_id) + role_server = Role_Servers.get_or_none(Role_Servers.role_id == role_id) if role_server is not None: permissions_mask = role_server.permissions permissions_list = server_permissions.get_permissions(permissions_mask) @@ -138,7 +143,9 @@ class Permissions_Servers: Role_Servers.save(role_server) @staticmethod - def delete_roles_permissions(role_id, removed_servers={}): + def delete_roles_permissions(role_id, removed_servers=None): + if removed_servers is None: + removed_servers = {} return Role_Servers.delete().where(Role_Servers.role_id == role_id).where(Role_Servers.server_id.in_(removed_servers)).execute() @staticmethod @@ -147,18 +154,71 @@ class Permissions_Servers: return Role_Servers.delete().where(Role_Servers.server_id == server_id).execute() @staticmethod - def get_user_permissions_list(user_id, server_id): - permissions_mask = '' - permissions_list = [] + def get_user_id_permissions_mask(user_id, server_id: str): + user = users_helper.get_user_model(user_id) + return server_permissions.get_user_permissions_mask(user, server_id) - user = users_helper.get_user(user_id) - if user['superuser'] == True: + @staticmethod + def get_user_permissions_mask(user: Users, server_id: str): + if user.superuser: + permissions_mask = '1' * len(server_permissions.get_permissions_list()) + else: + roles_list = users_helper.get_user_roles_id(user.user_id) + role_server = Role_Servers.select().where(Role_Servers.role_id.in_(roles_list)).where(Role_Servers.server_id == server_id).execute() + try: + permissions_mask = role_server[0].permissions + except IndexError: + permissions_mask = '0' * len(server_permissions.get_permissions_list()) + return permissions_mask + + @staticmethod + def get_server_user_list(server_id): + final_users = [] + server_roles = Role_Servers.select().where(Role_Servers.server_id == server_id) + # pylint: disable=singleton-comparison + super_users = Users.select().where(Users.superuser == True) + for role in server_roles: + users = User_Roles.select().where(User_Roles.role_id == role.role_id) + for user in users: + if user.user_id.user_id not in final_users: + final_users.append(user.user_id.user_id) + for suser in super_users: + if suser.user_id not in final_users: + final_users.append(suser.user_id) + return final_users + + @staticmethod + def get_user_id_permissions_list(user_id, server_id: str): + user = users_helper.get_user_model(user_id) + return server_permissions.get_user_permissions_list(user, server_id) + + @staticmethod + def get_user_permissions_list(user: Users, server_id: str): + if user.superuser: permissions_list = server_permissions.get_permissions_list() else: - roles_list = users_helper.get_user_roles_id(user_id) - role_server = Role_Servers.select().where(Role_Servers.role_id.in_(roles_list)).where(Role_Servers.server_id == int(server_id)).execute() - permissions_mask = role_server[0].permissions + permissions_mask = server_permissions.get_user_permissions_mask(user, server_id) permissions_list = server_permissions.get_permissions(permissions_mask) return permissions_list -server_permissions = Permissions_Servers() \ No newline at end of file + @staticmethod + def get_api_key_id_permissions_list(key_id, server_id: str): + key = ApiKeys.get(ApiKeys.token_id == key_id) + return server_permissions.get_api_key_permissions_list(key, server_id) + + @staticmethod + def get_api_key_permissions_list(key: ApiKeys, server_id: str): + user = key.user + if user.superuser and key.superuser: + return server_permissions.get_permissions_list() + else: + roles_list = users_helper.get_user_roles_id(user['user_id']) + role_server = Role_Servers.select().where(Role_Servers.role_id.in_(roles_list)).where(Role_Servers.server_id == server_id).execute() + user_permissions_mask = role_server[0].permissions + key_permissions_mask = key.server_permissions + permissions_mask = permission_helper.combine_masks(user_permissions_mask, key_permissions_mask) + permissions_list = server_permissions.get_permissions(permissions_mask) + return permissions_list + + +server_permissions = Permissions_Servers() diff --git a/app/classes/models/servers.py b/app/classes/models/servers.py index 3d80ae54..07b87d31 100644 --- a/app/classes/models/servers.py +++ b/app/classes/models/servers.py @@ -1,29 +1,19 @@ -import os -import sys import logging import datetime from app.classes.shared.helpers import helper -from app.classes.shared.console import console - from app.classes.shared.main_models import db_helper +try: + from peewee import SqliteDatabase, Model, ForeignKeyField, CharField, AutoField, DateTimeField, BooleanField, IntegerField, FloatField + +except ModuleNotFoundError as e: + helper.auto_installer_fix(e) + logger = logging.getLogger(__name__) peewee_logger = logging.getLogger('peewee') peewee_logger.setLevel(logging.INFO) - -try: - from peewee import * - from playhouse.shortcuts import model_to_dict - from enum import Enum - import yaml - -except ModuleNotFoundError as e: - logger.critical("Import Error: Unable to load {} module".format(e.name), exc_info=True) - console.critical("Import Error: Unable to load {} module".format(e.name)) - sys.exit(1) - -database = SqliteDatabase(helper.db_path, pragmas={ +database = SqliteDatabase(helper.db_path, pragmas = { 'journal_mode': 'wal', 'cache_size': -1024 * 10}) @@ -48,6 +38,7 @@ class Servers(Model): server_ip = CharField(default="127.0.0.1") server_port = IntegerField(default=25565) logs_delete_after = IntegerField(default=0) + type = CharField(default="minecraft-java") class Meta: table_name = "servers" @@ -77,6 +68,9 @@ class Server_Stats(Model): version = CharField(default="") updating = BooleanField(default=False) waiting_start = BooleanField(default=False) + first_run = BooleanField(default=True) + crashed = BooleanField(default=False) + downloading = BooleanField(default=False) class Meta: @@ -88,12 +82,22 @@ class Server_Stats(Model): # Servers Class #************************************************************************************************ class helper_servers: - + #************************************************************************************************ # Generic Servers Methods #************************************************************************************************ @staticmethod - def create_server(name: str, server_uuid: str, server_dir: str, backup_path: str, server_command: str, server_file: str, server_log_file: str, server_stop: str, server_port=25565): + def create_server( + name: str, + server_uuid: str, + server_dir: str, + backup_path: str, + server_command: str, + server_file: str, + server_log_file: str, + server_stop: str, + server_type: str, + server_port=25565): return Servers.insert({ Servers.server_name: name, Servers.server_uuid: server_uuid, @@ -106,9 +110,24 @@ class helper_servers: Servers.log_path: server_log_file, Servers.server_port: server_port, Servers.stop_command: server_stop, - Servers.backup_path: backup_path + Servers.backup_path: backup_path, + Servers.type: server_type }).execute() + + @staticmethod + def get_server_obj(server_id): + return Servers.get_by_id(server_id) + + @staticmethod + def get_server_type_by_id(server_id): + server_type = Servers.select().where(Servers.server_id == server_id).get() + return server_type.type + + @staticmethod + def update_server(server_obj): + return server_obj.save() + @staticmethod def remove_server(server_id): with database.atomic(): @@ -134,16 +153,18 @@ class helper_servers: def get_all_servers_stats(): servers = servers_helper.get_all_defined_servers() server_data = [] - - for s in servers: - latest = Server_Stats.select().where(Server_Stats.server_id == s.get('server_id')).order_by(Server_Stats.created.desc()).limit(1) - server_data.append({'server_data': s, "stats": db_helper.return_rows(latest)[0], "user_command_permission":True}) + try: + for s in servers: + latest = Server_Stats.select().where(Server_Stats.server_id == s.get('server_id')).order_by(Server_Stats.created.desc()).limit(1) + server_data.append({'server_data': s, "stats": db_helper.return_rows(latest)[0], "user_command_permission":True}) + except IndexError as ex: + logger.error(f"Stats collection failed with error: {ex}. Was a server just created?") return server_data @staticmethod def get_server_friendly_name(server_id): server_data = servers_helper.get_server_data_by_id(server_id) - friendly_name = "{} with ID: {}".format(server_data.get('server_name', None), server_data.get('server_id', 0)) + friendly_name = f"{server_data.get('server_name', None)} with ID: {server_data.get('server_id', 0)}" return friendly_name #************************************************************************************************ @@ -164,19 +185,82 @@ class helper_servers: return False return True + @staticmethod + def sever_crashed(server_id): + with database.atomic(): + Server_Stats.update(crashed=True).where(Server_Stats.server_id == server_id).execute() + + @staticmethod + def set_download(server_id): + with database.atomic(): + Server_Stats.update(downloading=True).where(Server_Stats.server_id == server_id).execute() + + @staticmethod + def finish_download(server_id): + with database.atomic(): + Server_Stats.update(downloading=False).where(Server_Stats.server_id == server_id).execute() + + @staticmethod + def get_download_status(server_id): + download_status = Server_Stats.select().where(Server_Stats.server_id == server_id).get() + return download_status.downloading + + + @staticmethod + def server_crash_reset(server_id): + with database.atomic(): + Server_Stats.update(crashed=False).where(Server_Stats.server_id == server_id).execute() + + @staticmethod + def is_crashed(server_id): + svr = Server_Stats.select().where(Server_Stats.server_id == server_id).get() + #pylint: disable=singleton-comparison + if svr.crashed == True: + return True + else: + return False + @staticmethod def set_update(server_id, value): try: - row = Server_Stats.select().where(Server_Stats.server_id == server_id) + #Checks if server even exists + Server_Stats.select().where(Server_Stats.server_id == server_id) except Exception as ex: - logger.error("Database entry not found. ".format(ex)) + logger.error(f"Database entry not found! {ex}") with database.atomic(): Server_Stats.update(updating=value).where(Server_Stats.server_id == server_id).execute() + @staticmethod + def get_update_status(server_id): + update_status = Server_Stats.select().where(Server_Stats.server_id == server_id).get() + return update_status.updating + + @staticmethod + def set_first_run(server_id): + #Sets first run to false + try: + #Checks if server even exists + Server_Stats.select().where(Server_Stats.server_id == server_id) + except Exception as ex: + logger.error(f"Database entry not found! {ex}") + return + with database.atomic(): + Server_Stats.update(first_run=False).where(Server_Stats.server_id == server_id).execute() + + @staticmethod + def get_first_run(server_id): + first_run = Server_Stats.select().where(Server_Stats.server_id == server_id).get() + return first_run.first_run + @staticmethod def get_TTL_without_player(server_id): last_stat = Server_Stats.select().where(Server_Stats.server_id == server_id).order_by(Server_Stats.created.desc()).first() - last_stat_with_player = Server_Stats.select().where(Server_Stats.server_id == server_id).where(Server_Stats.online > 0).order_by(Server_Stats.created.desc()).first() + last_stat_with_player = (Server_Stats + .select() + .where(Server_Stats.server_id == server_id) + .where(Server_Stats.online > 0) + .order_by(Server_Stats.created.desc()) + .first()) return last_stat.created - last_stat_with_player.created @staticmethod @@ -186,13 +270,14 @@ class helper_servers: if (time_limit == -1) or (ttl_no_players > time_limit): can = True return can - + @staticmethod def set_waiting_start(server_id, value): try: - row = Server_Stats.select().where(Server_Stats.server_id == server_id) + # Checks if server even exists + Server_Stats.select().where(Server_Stats.server_id == server_id) except Exception as ex: - logger.error("Database entry not found. ".format(ex)) + logger.error(f"Database entry not found! {ex}") with database.atomic(): Server_Stats.update(waiting_start=value).where(Server_Stats.server_id == server_id).execute() @@ -202,4 +287,4 @@ class helper_servers: return waiting_start.waiting_start -servers_helper = helper_servers() \ No newline at end of file +servers_helper = helper_servers() diff --git a/app/classes/models/users.py b/app/classes/models/users.py index f959a5bb..78966556 100644 --- a/app/classes/models/users.py +++ b/app/classes/models/users.py @@ -1,29 +1,21 @@ -import os -import sys import logging import datetime - -from app.classes.shared.helpers import helper -from app.classes.shared.console import console +from typing import Optional, Union from app.classes.models.roles import Roles, roles_helper +from app.classes.shared.helpers import helper + +try: + from peewee import SqliteDatabase, Model, ForeignKeyField, CharField, AutoField, DateTimeField, BooleanField, CompositeKey, DoesNotExist, JOIN + from playhouse.shortcuts import model_to_dict + +except ModuleNotFoundError as e: + helper.auto_installer_fix(e) logger = logging.getLogger(__name__) peewee_logger = logging.getLogger('peewee') peewee_logger.setLevel(logging.INFO) - -try: - from peewee import * - from playhouse.shortcuts import model_to_dict - from enum import Enum - import yaml - -except ModuleNotFoundError as e: - logger.critical("Import Error: Unable to load {} module".format(e.name), exc_info=True) - console.critical("Import Error: Unable to load {} module".format(e.name)) - sys.exit(1) - -database = SqliteDatabase(helper.db_path, pragmas={ +database = SqliteDatabase(helper.db_path, pragmas = { 'journal_mode': 'wal', 'cache_size': -1024 * 10}) @@ -38,15 +30,36 @@ class Users(Model): last_ip = CharField(default="") username = CharField(default="", unique=True, index=True) password = CharField(default="") + email = CharField(default="default@example.com") enabled = BooleanField(default=True) superuser = BooleanField(default=False) - api_token = CharField(default="", unique=True, index=True) # we may need to revisit this lang = CharField(default="en_EN") + support_logs = CharField(default = '') + valid_tokens_from = DateTimeField(default=datetime.datetime.now) + server_order = CharField(default="") class Meta: table_name = "users" database = database + +# ************************************************************************************************ +# API Keys Class +# ************************************************************************************************ +class ApiKeys(Model): + token_id = AutoField() + name = CharField(default='', unique=True, index=True) + created = DateTimeField(default=datetime.datetime.now) + user_id = ForeignKeyField(Users, backref='api_token', index=True) + server_permissions = CharField(default='00000000') + crafty_permissions = CharField(default='000') + superuser = BooleanField(default=False) + + class Meta: + table_name = 'api_keys' + database = database + + #************************************************************************************************ # User Roles Class #************************************************************************************************ @@ -70,7 +83,7 @@ class helper_users: @staticmethod def get_all_users(): - query = Users.select() + query = Users.select().where(Users.username != "system") return query @staticmethod @@ -79,25 +92,11 @@ class helper_users: @staticmethod def get_user_id_by_name(username): - if username == "SYSTEM": - return 0 try: return (Users.get(Users.username == username)).user_id except DoesNotExist: return None - @staticmethod - def get_user_by_api_token(token: str): - query = Users.select().where(Users.api_token == token) - - if query.exists(): - user = model_to_dict(Users.get(Users.api_token == token)) - # I know it should apply it without setting it but I'm just making sure - user = users_helper.add_user_roles(user) - return user - else: - return {} - @staticmethod def user_query(user_id): user_query = Users.select().where(Users.user_id == user_id) @@ -108,17 +107,18 @@ class helper_users: if user_id == 0: return { 'user_id': 0, - 'created': None, - 'last_login': None, - 'last_update': None, + 'created': '10/24/2019, 11:34:00', + 'last_login': '10/24/2019, 11:34:00', + 'last_update': '10/24/2019, 11:34:00', 'last_ip': "127.27.23.89", 'username': "SYSTEM", 'password': None, + 'email': "default@example.com", 'enabled': True, - 'superuser': False, - 'api_token': None, + 'superuser': True, 'roles': [], 'servers': [], + 'support_logs': '', } user = model_to_dict(Users.get(Users.user_id == user_id)) @@ -131,20 +131,30 @@ class helper_users: return {} @staticmethod - def add_user(username, password=None, api_token=None, enabled=True, superuser=False): + def check_system_user(user_id): + try: + result = Users.get(Users.user_id == user_id).user_id == user_id + if result: + return True + except: + return False + + @staticmethod + def get_user_model(user_id: str) -> Users: + user = Users.get(Users.user_id == user_id) + user = users_helper.add_user_roles(user) + return user + + @staticmethod + def add_user(username: str, password: Optional[str] = None, email: Optional[str] = None, enabled: bool = True, superuser: bool = False) -> str: if password is not None: pw_enc = helper.encode_pass(password) else: pw_enc = None - if api_token is None: - api_token = users_helper.new_api_token() - else: - if type(api_token) is not str and len(api_token) != 32: - raise ValueError("API token must be a 32 character string") user_id = Users.insert({ Users.username: username.lower(), Users.password: pw_enc, - Users.api_token: api_token, + Users.email: email, Users.enabled: enabled, Users.superuser: superuser, Users.created: helper.get_time_as_string() @@ -152,10 +162,30 @@ class helper_users: return user_id @staticmethod - def update_user(user_id, up_data={}): + def update_user(user_id, up_data=None): + if up_data is None: + up_data = {} if up_data: Users.update(up_data).where(Users.user_id == user_id).execute() + @staticmethod + def update_server_order(user_id, user_server_order): + Users.update(server_order = user_server_order).where(Users.user_id == user_id).execute() + + @staticmethod + def get_server_order(user_id): + return Users.select().where(Users.user_id == user_id) + + @staticmethod + def get_super_user_list(): + final_users = [] + # pylint: disable=singleton-comparison + super_users = Users.select().where(Users.superuser == True) + for suser in super_users: + if suser.user_id not in final_users: + final_users.append(suser.user_id) + return final_users + @staticmethod def remove_user(user_id): with database.atomic(): @@ -163,20 +193,16 @@ class helper_users: user = Users.get(Users.user_id == user_id) return user.delete_instance() + @staticmethod + def set_support_path(user_id, support_path): + Users.update(support_logs = support_path).where(Users.user_id == user_id).execute() + @staticmethod def user_id_exists(user_id): if not users_helper.get_user(user_id): return False return True - @staticmethod - def new_api_token(): - while True: - token = helper.random_string_generator(32) - test = list(Users.select(Users.user_id).where(Users.api_token == token)) - if len(test) == 0: - return token - #************************************************************************************************ # User_Roles Methods #************************************************************************************************ @@ -209,8 +235,8 @@ class helper_users: }).execute() @staticmethod - def add_user_roles(user): - if type(user) == dict: + def add_user_roles(user: Union[dict, Users]): + if isinstance(user, dict): user_id = user['user_id'] else: user_id = user.user_id @@ -223,7 +249,11 @@ class helper_users: for r in roles_query: roles.add(r.role_id.role_id) - user['roles'] = roles + if isinstance(user, dict): + user['roles'] = roles + else: + user.roles = roles + #logger.debug("user: ({}) {}".format(user_id, user)) return user @@ -243,5 +273,41 @@ class helper_users: def remove_roles_from_role_id(role_id): User_Roles.delete().where(User_Roles.role_id == role_id).execute() +# ************************************************************************************************ +# ApiKeys Methods +# ************************************************************************************************ -users_helper = helper_users() \ No newline at end of file + @staticmethod + def get_user_api_keys(user_id: str): + return ApiKeys.select().where(ApiKeys.user_id == user_id).execute() + + @staticmethod + def get_user_api_key(key_id: str) -> ApiKeys: + return ApiKeys.get(ApiKeys.token_id == key_id) + + @staticmethod + def add_user_api_key( + name: str, + user_id: str, + superuser: bool = False, + server_permissions_mask: Optional[str] = None, + crafty_permissions_mask: Optional[str] = None): + return ApiKeys.insert({ + ApiKeys.name: name, + ApiKeys.user_id: user_id, + **({ApiKeys.server_permissions: server_permissions_mask} if server_permissions_mask is not None else {}), + **({ApiKeys.crafty_permissions: crafty_permissions_mask} if crafty_permissions_mask is not None else {}), + ApiKeys.superuser: superuser + }).execute() + + @staticmethod + def delete_user_api_keys(user_id: str): + ApiKeys.delete().where(ApiKeys.user_id == user_id).execute() + + @staticmethod + def delete_user_api_key(key_id: str): + ApiKeys.delete().where(ApiKeys.token_id == key_id).execute() + + + +users_helper = helper_users() diff --git a/app/classes/shared/authentication.py b/app/classes/shared/authentication.py new file mode 100644 index 00000000..25053d0b --- /dev/null +++ b/app/classes/shared/authentication.py @@ -0,0 +1,79 @@ +import logging +import time +from typing import Optional, Dict, Any, Tuple + +from app.classes.models.users import users_helper, ApiKeys +from app.classes.shared.helpers import helper + +try: + import jwt + from jwt import PyJWTError + +except ModuleNotFoundError as e: + helper.auto_installer_fix(e) + +logger = logging.getLogger(__name__) + +class Authentication: + def __init__(self): + self.secret = "my secret" + self.secret = helper.get_setting('apikey_secret', None) + + if self.secret is None or self.secret == 'random': + self.secret = helper.random_string_generator(64) + + @staticmethod + def generate(user_id, extra=None): + if extra is None: + extra = {} + return jwt.encode( + { + 'user_id': user_id, + 'iat': int(time.time()), + **extra + }, + authentication.secret, + algorithm="HS256" + ) + + @staticmethod + def read(token): + return jwt.decode(token, authentication.secret, algorithms=["HS256"]) + + @staticmethod + def check_no_iat(token) -> Optional[Dict[str, Any]]: + try: + return jwt.decode(token, authentication.secret, algorithms=["HS256"]) + except PyJWTError as error: + logger.debug("Error while checking JWT token: ", exc_info=error) + return None + + @staticmethod + def check(token) -> Optional[Tuple[Optional[ApiKeys], Dict[str, Any], Dict[str, Any]]]: + try: + data = jwt.decode(token, authentication.secret, algorithms=["HS256"]) + except PyJWTError as error: + logger.debug("Error while checking JWT token: ", exc_info=error) + return None + iat: int = data['iat'] + key: Optional[ApiKeys] = None + if 'token_id' in data: + key_id = data['token_id'] + key = users_helper.get_user_api_key(key_id) + if key is None: + return None + user_id: str = data['user_id'] + user = users_helper.get_user(user_id) + # TODO: Have a cache or something so we don't constantly have to query the database + if int(user.get('valid_tokens_from').timestamp()) < iat: + # Success! + return key, data, user + else: + return None + + @staticmethod + def check_bool(token) -> bool: + return authentication.check(token) is not None + + +authentication = Authentication() diff --git a/app/classes/shared/cmd.py b/app/classes/shared/command.py similarity index 57% rename from app/classes/shared/cmd.py rename to app/classes/shared/command.py index dfce718a..18b4f868 100644 --- a/app/classes/shared/cmd.py +++ b/app/classes/shared/command.py @@ -1,26 +1,16 @@ -import os import sys import cmd import time import threading import logging -logger = logging.getLogger(__name__) - from app.classes.shared.console import console from app.classes.shared.helpers import helper from app.classes.web.websocket_helper import websocket_helper -try: - import requests +logger = logging.getLogger(__name__) -except ModuleNotFoundError as e: - logger.critical("Import Error: Unable to load {} module".format(e.name), exc_info=True) - console.critical("Import Error: Unable to load {} module".format(e.name)) - sys.exit(1) - - -class MainPrompt(cmd.Cmd, object): +class MainPrompt(cmd.Cmd): def __init__(self, tasks_manager, migration_manager): super().__init__() @@ -28,67 +18,60 @@ class MainPrompt(cmd.Cmd, object): self.migration_manager = migration_manager # overrides the default Prompt - prompt = "Crafty Controller v{} > ".format(helper.get_version_string()) + prompt = f"Crafty Controller v{helper.get_version_string()} > " @staticmethod def emptyline(): pass - @staticmethod - def _clean_shutdown(): - exit_file = os.path.join(helper.root_dir, "exit.txt") - try: - with open(exit_file, 'w') as f: - f.write("exit") - - except Exception as e: - logger.critical("Unable to write exit file due to error: {}".format(e)) - console.critical("Unable to write exit file due to error: {}".format(e)) - + #pylint: disable=unused-argument def do_exit(self, line): + self.tasks_manager._main_graceful_exit() self.universal_exit() - + def do_migrations(self, line): - if (line == 'up'): + if line == 'up': self.migration_manager.up() - elif (line == 'down'): + elif line == 'down': self.migration_manager.down() - elif (line == 'done'): + elif line == 'done': console.info(self.migration_manager.done) - elif (line == 'todo'): + elif line == 'todo': console.info(self.migration_manager.todo) - elif (line == 'diff'): + elif line == 'diff': console.info(self.migration_manager.diff) - elif (line == 'info'): - console.info('Done: {}'.format(self.migration_manager.done)) - console.info('FS: {}'.format(self.migration_manager.todo)) - console.info('Todo: {}'.format(self.migration_manager.diff)) - elif (line.startswith('add ')): + elif line == 'info': + console.info(f'Done: {self.migration_manager.done}') + console.info(f'FS: {self.migration_manager.todo}') + console.info(f'Todo: {self.migration_manager.diff}') + elif line.startswith('add '): migration_name = line[len('add '):] self.migration_manager.create(migration_name, False) else: console.info('Unknown migration command') - - def do_threads(self, line): + + @staticmethod + def do_threads(_line): for thread in threading.enumerate(): - print(f'Name: {thread.name} IDENT: {thread.ident}') + if sys.version_info >= (3, 8): + print(f'Name: {thread.name} Identifier: {thread.ident} TID/PID: {thread.native_id}') + else: + print(f'Name: {thread.name} Identifier: {thread.ident}') def universal_exit(self): logger.info("Stopping all server daemons / threads") console.info("Stopping all server daemons / threads - This may take a few seconds") websocket_helper.disconnect_all() - self._clean_shutdown() console.info('Waiting for main thread to stop') while True: if self.tasks_manager.get_main_thread_run_status(): sys.exit(0) time.sleep(1) - @staticmethod def help_exit(): console.help("Stops the server if running, Exits the program") - + @staticmethod def help_migrations(): console.help("Only for advanced users. Use with caution") diff --git a/app/classes/shared/console.py b/app/classes/shared/console.py index 18733adc..96b1e324 100644 --- a/app/classes/shared/console.py +++ b/app/classes/shared/console.py @@ -8,14 +8,11 @@ try: from colorama import init from termcolor import colored -except ModuleNotFoundError as e: - logger.critical("Import Error: Unable to load {} module".format(e.name), exc_info=True) - print("Import Error: Unable to load {} module".format(e.name)) +except ModuleNotFoundError as ex: + logger.critical(f"Import Error: Unable to load {ex.name} module", exc_info=True) + print(f"Import Error: Unable to load {ex.name} module") from app.classes.shared.installer import installer installer.do_install() - sys.exit(1) - - class Console: def __init__(self): @@ -49,28 +46,27 @@ class Console: def debug(self, message): dt = datetime.datetime.now().strftime("%Y-%m-%d %I:%M:%S %p") - self.magenta("[+] Crafty: {} - DEBUG:\t{}".format(dt, message)) + self.magenta(f"[+] Crafty: {dt} - DEBUG:\t{message}") def info(self, message): dt = datetime.datetime.now().strftime("%Y-%m-%d %I:%M:%S %p") - self.white("[+] Crafty: {} - INFO:\t{}".format(dt, message)) + self.white(f"[+] Crafty: {dt} - INFO:\t{message}") def warning(self, message): dt = datetime.datetime.now().strftime("%Y-%m-%d %I:%M:%S %p") - self.cyan("[+] Crafty: {} - WARNING:\t{}".format(dt, message)) + self.cyan(f"[+] Crafty: {dt} - WARNING:\t{message}") def error(self, message): dt = datetime.datetime.now().strftime("%Y-%m-%d %I:%M:%S %p") - self.yellow("[+] Crafty: {} - ERROR:\t{}".format(dt, message)) + self.yellow(f"[+] Crafty: {dt} - ERROR:\t{message}") def critical(self, message): dt = datetime.datetime.now().strftime("%Y-%m-%d %I:%M:%S %p") - self.red("[+] Crafty: {} - CRITICAL:\t{}".format(dt, message)) + self.red(f"[+] Crafty: {dt} - CRITICAL:\t{message}") def help(self, message): dt = datetime.datetime.now().strftime("%Y-%m-%d %I:%M:%S %p") - self.green("[+] Crafty: {} - HELP:\t{}".format(dt, message)) + self.green(f"[+] Crafty: {dt} - HELP:\t{message}") console = Console() - diff --git a/app/classes/shared/exceptions.py b/app/classes/shared/exceptions.py index a5de82fa..05a46fdb 100644 --- a/app/classes/shared/exceptions.py +++ b/app/classes/shared/exceptions.py @@ -1,8 +1,8 @@ class CraftyException(Exception): - pass + pass class DatabaseException(CraftyException): - pass + pass class SchemaError(DatabaseException): - pass \ No newline at end of file + pass diff --git a/app/classes/shared/file_helpers.py b/app/classes/shared/file_helpers.py new file mode 100644 index 00000000..205c9066 --- /dev/null +++ b/app/classes/shared/file_helpers.py @@ -0,0 +1,101 @@ +import os +import shutil +import logging +import pathlib +from zipfile import ZipFile, ZIP_DEFLATED + +logger = logging.getLogger(__name__) + +class FileHelpers: + allowed_quotes = [ + "\"", + "'", + "`" + ] + + def del_dirs(self, path): + path = pathlib.Path(path) + for sub in path.iterdir(): + if sub.is_dir(): + # Delete folder if it is a folder + self.del_dirs(sub) + else: + # Delete file if it is a file: + sub.unlink() + + # This removes the top-level folder: + path.rmdir() + return True + + @staticmethod + def del_file(path): + path = pathlib.Path(path) + try: + logger.debug(f"Deleting file: {path}") + #Remove the file + os.remove(path) + return True + except FileNotFoundError: + logger.error(f"Path specified is not a file or does not exist. {path}") + return False + + @staticmethod + def copy_dir(src_path, dest_path, dirs_exist_ok=False): + # pylint: disable=unexpected-keyword-arg + shutil.copytree(src_path, dest_path, dirs_exist_ok=dirs_exist_ok) + + @staticmethod + def copy_file(src_path, dest_path): + shutil.copy(src_path, dest_path) + + def move_dir(self, src_path, dest_path): + self.copy_dir(src_path, dest_path) + self.del_dirs(src_path) + + def move_file(self, src_path, dest_path): + self.copy_file(src_path, dest_path) + self.del_file(src_path) + + @staticmethod + def make_archive(path_to_destination, path_to_zip): + # create a ZipFile object + path_to_destination += '.zip' + with ZipFile(path_to_destination, 'w') as z: + for root, _dirs, files in os.walk(path_to_zip, topdown=True): + ziproot = path_to_zip + for file in files: + try: + logger.info(f"backing up: {os.path.join(root, file)}") + if os.name == "nt": + z.write(os.path.join(root, file), os.path.join(root.replace(ziproot, ""), file)) + else: + z.write(os.path.join(root, file), os.path.join(root.replace(ziproot, "/"), file)) + + except Exception as e: + logger.warning(f"Error backing up: {os.path.join(root, file)}! - Error was: {e}") + + + return True + + @staticmethod + def make_compressed_archive(path_to_destination, path_to_zip): + # create a ZipFile object + path_to_destination += '.zip' + with ZipFile(path_to_destination, 'w', ZIP_DEFLATED) as z: + for root, _dirs, files in os.walk(path_to_zip, topdown=True): + ziproot = path_to_zip + for file in files: + try: + logger.info(f"backing up: {os.path.join(root, file)}") + if os.name == "nt": + z.write(os.path.join(root, file), os.path.join(root.replace(ziproot, ""), file)) + else: + z.write(os.path.join(root, file), os.path.join(root.replace(ziproot, "/"), file)) + + except Exception as e: + logger.warning(f"Error backing up: {os.path.join(root, file)}! - Error was: {e}") + + + return True + +file_helper = FileHelpers() diff --git a/app/classes/shared/helpers.py b/app/classes/shared/helpers.py index b02e1d8e..1d9756c2 100644 --- a/app/classes/shared/helpers.py +++ b/app/classes/shared/helpers.py @@ -13,25 +13,29 @@ import logging import html import zipfile import pathlib -import shutil -from requests import get - +import ctypes from datetime import datetime from socket import gethostname +from contextlib import suppress +import psutil from app.classes.shared.console import console +from app.classes.shared.installer import installer +from app.classes.shared.file_helpers import file_helper +from app.classes.web.websocket_helper import websocket_helper logger = logging.getLogger(__name__) try: import requests + from requests import get from OpenSSL import crypto from argon2 import PasswordHasher -except ModuleNotFoundError as e: - logger.critical("Import Error: Unable to load {} module".format(e.name), exc_info=True) - console.critical("Import Error: Unable to load {} module".format(e.name)) - sys.exit(1) +except ModuleNotFoundError as err: + logger.critical(f"Import Error: Unable to load {err.name} module", exc_info=True) + print(f"Import Error: Unable to load {err.name} module") + installer.do_install() class Helpers: allowed_quotes = [ @@ -58,14 +62,20 @@ class Helpers: self.passhasher = PasswordHasher() self.exiting = False + @staticmethod + def auto_installer_fix(ex): + logger.critical(f"Import Error: Unable to load {ex.name} module", exc_info=True) + print(f"Import Error: Unable to load {ex.name} module") + installer.do_install() + def float_to_string(self, gbs: int): s = str(float(gbs) * 1000).rstrip("0").rstrip(".") return s def check_file_perms(self, path): try: - fp = open(path, "r").close() - logger.info("{} is readable".format(path)) + open(path, "r", encoding='utf-8').close() + logger.info(f"{path} is readable") return True except PermissionError: return False @@ -78,30 +88,52 @@ class Helpers: return True else: return False - logger.error("{} does not exist".format(file)) + logger.error(f"{file} does not exist") return True + def get_servers_root_dir(self): + return self.servers_dir + @staticmethod def check_internet(): try: requests.get('https://google.com', timeout=1) return True - except Exception as err: + except Exception: return False @staticmethod def check_port(server_port): try: - host_public = get('https://api.ipify.org').text - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(10.0) - result = sock.connect_ex((host_public ,server_port)) - sock.close() - if result == 0: - return True - else: - return False - except Exception as err: + ip = get('https://api.ipify.org').content.decode('utf8') + except: + ip = 'google.com' + a_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + a_socket.settimeout(20.0) + + location = (ip, server_port) + result_of_check = a_socket.connect_ex(location) + + a_socket.close() + + if result_of_check == 0: + return True + else: + return False + + @staticmethod + def check_server_conn(server_port): + a_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + a_socket.settimeout(10.0) + ip = '127.0.0.1' + + location = (ip, server_port) + result_of_check = a_socket.connect_ex(location) + a_socket.close() + + if result_of_check == 0: + return True + else: return False @staticmethod @@ -113,7 +145,7 @@ class Helpers: esc = False # whether an escape character was encountered stch = None # if we're dealing with a quote, save the quote type here. Nested quotes to be dealt with by the command for c in cmd_in: # for character in string - if np == True: # if set, begin a new argument and increment the command index. Continue the loop. + if np: # if set, begin a new argument and increment the command index. Continue the loop. if c == ' ': continue else: @@ -128,52 +160,35 @@ class Helpers: else: if c == '\\': # if the current character is an escape character, set the esc flag and continue to next loop esc = True - elif c == ' ' and stch is None: # if we encounter a space and are not dealing with a quote, set the new argument flag and continue to next loop + elif c == ' ' and stch is None: # if we encounter a space and are not dealing with a quote, + # set the new argument flag and continue to next loop np = True elif c == stch: # if we encounter the character that matches our start quote, end the quote and continue to next loop stch = None - elif stch is None and (c in Helpers.allowed_quotes): # if we're not in the middle of a quote and we get a quotable character, start a quote and proceed to the next loop + elif stch is None and (c in Helpers.allowed_quotes): # if we're not in the middle of a quote and we get a quotable character, + # start a quote and proceed to the next loop stch = c else: # else, just store the character in the current arg cmd_out[ci] += c return cmd_out - def check_for_old_logs(self, db_helper): - servers = db_helper.get_all_defined_servers() - for server in servers: - logs_path = os.path.split(server['log_path'])[0] - latest_log_file = os.path.split(server['log_path'])[1] - logs_delete_after = int(server['logs_delete_after']) - if logs_delete_after == 0: - continue - - log_files = list(filter( - lambda val: val != latest_log_file, - os.listdir(logs_path) - )) - for log_file in log_files: - log_file_path = os.path.join(logs_path, log_file) - if self.check_file_exists(log_file_path) and \ - self.is_file_older_than_x_days(log_file_path, logs_delete_after): - os.remove(log_file_path) - def get_setting(self, key, default_return=False): try: - with open(self.settings_file, "r") as f: + with open(self.settings_file, "r", encoding='utf-8') as f: data = json.load(f) if key in data.keys(): return data.get(key) else: - logger.error("Config File Error: setting {} does not exist".format(key)) - console.error("Config File Error: setting {} does not exist".format(key)) + logger.error(f"Config File Error: setting {key} does not exist") + console.error(f"Config File Error: setting {key} does not exist") return default_return except Exception as e: - logger.critical("Config File Error: Unable to read {} due to {}".format(self.settings_file, e)) - console.critical("Config File Error: Unable to read {} due to {}".format(self.settings_file, e)) + logger.critical(f"Config File Error: Unable to read {self.settings_file} due to {e}") + console.critical(f"Config File Error: Unable to read {self.settings_file} due to {e}") return default_return @@ -192,11 +207,11 @@ class Helpers: def get_version(self): version_data = {} try: - with open(os.path.join(self.config_dir, 'version.json'), 'r') as f: + with open(os.path.join(self.config_dir, 'version.json'), 'r', encoding='utf-8') as f: version_data = json.load(f) except Exception as e: - console.critical("Unable to get version data!") + console.critical(f"Unable to get version data! \n{e}") return version_data @@ -210,7 +225,7 @@ class Helpers: try: data = json.loads(r.content) except Exception as e: - logger.error("Failed to load json content with error: {} ".format(e)) + logger.error(f"Failed to load json content with error: {e}") return data @@ -218,23 +233,15 @@ class Helpers: def get_version_string(self): version_data = self.get_version() + major = version_data.get('major', '?') + minor = version_data.get('minor', '?') + sub = version_data.get('sub', '?') + meta = version_data.get('meta', '?') + # set some defaults if we don't get version_data from our helper - version = "{}.{}.{}-{}".format(version_data.get('major', '?'), - version_data.get('minor', '?'), - version_data.get('sub', '?'), - version_data.get('meta', '?')) + version = f"{major}.{minor}.{sub}-{meta}" return str(version) - def do_exit(self): - exit_file = os.path.join(self.root_dir, 'exit.txt') - try: - open(exit_file, 'a').close() - - except Exception as e: - logger.critical("Unable to create exit file!") - console.critical("Unable to create exit file!") - sys.exit(1) - def encode_pass(self, password): return self.passhasher.hash(password) @@ -243,7 +250,6 @@ class Helpers: self.passhasher.verify(currenthash, password) return True except: - pass return False def log_colors(self, line): @@ -256,15 +262,18 @@ class Helpers: (r'(\[.+?/INFO\])', r'\1'), (r'(\[.+?/WARN\])', r'\1'), (r'(\[.+?/ERROR\])', r'\1'), + (r'(\[.+?/FATAL\])', r'\1'), (r'(\w+?\[/\d+?\.\d+?\.\d+?\.\d+?\:\d+?\])', r'\1'), (r'\[(\d\d:\d\d:\d\d)\]', r'[\1]'), (r'(\[.+? INFO\])', r'\1'), (r'(\[.+? WARN\])', r'\1'), - (r'(\[.+? ERROR\])', r'\1') + (r'(\[.+? ERROR\])', r'\1'), + (r'(\[.+? FATAL\])', r'\1') ] # highlight users keywords for keyword in user_keywords: + # pylint: disable=consider-using-f-string search_replace = (r'({})'.format(keyword), r'\1') replacements.append(search_replace) @@ -273,10 +282,23 @@ class Helpers: return line + + def validate_traversal(self, base_path, filename): + logger.debug(f"Validating traversal (\"{base_path}\", \"{filename}\")") + base = pathlib.Path(base_path).resolve() + file = pathlib.Path(filename) + fileabs = base.joinpath(file).resolve() + cp = pathlib.Path(os.path.commonpath([base, fileabs])) + if base == cp: + return fileabs + else: + raise ValueError("Path traversal detected") + + def tail_file(self, file_name, number_lines=20): if not self.check_file_exists(file_name): - logger.warning("Unable to find file to tail: {}".format(file_name)) - return ["Unable to find file to tail: {}".format(file_name)] + logger.warning(f"Unable to find file to tail: {file_name}") + return [f"Unable to find file to tail: {file_name}"] # length of lines is X char here avg_line_length = 255 @@ -285,7 +307,7 @@ class Helpers: line_buffer = number_lines * avg_line_length # open our file - with open(file_name, 'r') as f: + with open(file_name, 'r', encoding='utf-8') as f: # seek f.seek(0, 2) @@ -301,8 +323,7 @@ class Helpers: lines = f.readlines() except Exception as e: - logger.warning('Unable to read a line in the file:{} - due to error: {}'.format(file_name, e)) - pass + logger.warning(f'Unable to read a line in the file:{file_name} - due to error: {e}') # now we are done getting the lines, let's return it return lines @@ -311,16 +332,28 @@ class Helpers: def check_writeable(path: str): filename = os.path.join(path, "tempfile.txt") try: - fp = open(filename, "w").close() + open(filename, "w", encoding='utf-8').close() os.remove(filename) - logger.info("{} is writable".format(filename)) + logger.info(f"{filename} is writable") return True except Exception as e: - logger.critical("Unable to write to {} - Error: {}".format(path, e)) + logger.critical(f"Unable to write to {path} - Error: {e}") return False + def checkRoot(self): + if self.is_os_windows(): + if ctypes.windll.shell32.IsUserAnAdmin() == 1: + return True + else: + return False + else: + if os.geteuid() == 0: + return True + else: + return False + def unzipFile(self, zip_path): new_dir_list = zip_path.split('/') new_dir = '' @@ -336,24 +369,17 @@ class Helpers: try: with zipfile.ZipFile(zip_path, 'r') as zip_ref: zip_ref.extractall(tempDir) - for i in range(len(zip_ref.filelist)): + for i in enumerate(zip_ref.filelist): if len(zip_ref.filelist) > 1 or not zip_ref.filelist[i].filename.endswith('/'): - test = zip_ref.filelist[i].filename break - path_list = test.split('/') - root_path = path_list[0] - ''' - if len(path_list) > 1: - for i in range(len(path_list) - 2): - root_path = os.path.join(root_path, path_list[i + 1]) -''' + full_root_path = tempDir for item in os.listdir(full_root_path): try: - shutil.move(os.path.join(full_root_path, item), os.path.join(new_dir, item)) + file_helper.move_dir(os.path.join(full_root_path, item), os.path.join(new_dir, item)) except Exception as ex: - logger.error('ERROR IN ZIP IMPORT: {}'.format(ex)) + logger.error(f'ERROR IN ZIP IMPORT: {ex}') except Exception as ex: print(ex) else: @@ -370,27 +396,28 @@ class Helpers: # if not writeable, let's bomb out if not writeable: - logger.critical("Unable to write to {} directory!".format(self.root_dir)) + logger.critical(f"Unable to write to {self.root_dir} directory!") sys.exit(1) # ensure the log directory is there try: - os.makedirs(os.path.join(self.root_dir, 'logs')) + with suppress(FileExistsError): + os.makedirs(os.path.join(self.root_dir, 'logs')) except Exception as e: - console.error("Failed to make logs directory with error: {} ".format(e)) + console.error(f"Failed to make logs directory with error: {e} ") # ensure the log file is there try: - open(log_file, 'a').close() + open(log_file, 'a', encoding='utf-8').close() except Exception as e: - console.critical("Unable to open log file!") + console.critical(f"Unable to open log file! {e}") sys.exit(1) # del any old session.lock file as this is a new session try: os.remove(session_log_file) except Exception as e: - logger.error("Deleting Session.lock failed with error: {} ".format(e)) + logger.error(f"Deleting Session.lock failed with error: {e}") @staticmethod def get_time_as_string(): @@ -399,10 +426,10 @@ class Helpers: @staticmethod def check_file_exists(path: str): - logger.debug('Looking for path: {}'.format(path)) + logger.debug(f'Looking for path: {path}') if os.path.exists(path) and os.path.isfile(path): - logger.debug('Found path: {}'.format(path)) + logger.debug(f'Found path: {path}') return True else: return False @@ -411,18 +438,20 @@ class Helpers: def human_readable_file_size(num: int, suffix='B'): for unit in ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z']: if abs(num) < 1024.0: + # pylint: disable=consider-using-f-string return "%3.1f%s%s" % (num, unit, suffix) num /= 1024.0 + # pylint: disable=consider-using-f-string return "%.1f%s%s" % (num, 'Y', suffix) @staticmethod def check_path_exists(path: str): if not path: return False - logger.debug('Looking for path: {}'.format(path)) + logger.debug(f'Looking for path: {path}') if os.path.exists(path): - logger.debug('Found path: {}'.format(path)) + logger.debug(f'Found path: {path}') return True else: return False @@ -434,17 +463,17 @@ class Helpers: if os.path.exists(path) and os.path.isfile(path): try: - with open(path, 'r') as f: + with open(path, 'r', encoding='utf-8') as f: for line in (f.readlines() [-lines:]): contents = contents + line return contents except Exception as e: - logger.error("Unable to read file: {}. \n Error: ".format(path, e)) + logger.error(f"Unable to read file: {path}. \n Error: {e}") return False else: - logger.error("Unable to read file: {}. File not found, or isn't a file.".format(path)) + logger.error(f"Unable to read file: {path}. File not found, or isn't a file.") return False def create_session_file(self, ignore=False): @@ -459,13 +488,19 @@ class Helpers: data = json.loads(file_data) pid = data.get('pid') started = data.get('started') - console.critical("Another Crafty Controller agent seems to be running...\npid: {} \nstarted on: {}".format(pid, started)) - except Exception as e: - logger.error("Failed to locate existing session.lock with error: {} ".format(e)) - console.error("Failed to locate existing session.lock with error: {} ".format(e)) - + if psutil.pid_exists(pid): + console.critical(f"Another Crafty Controller agent seems to be running...\npid: {pid} \nstarted on: {started}") + logger.critical("Found running crafty process. Exiting.") + sys.exit(1) + else: + logger.info("No process found for pid. Assuming crafty crashed. Deleting stale session.lock") + os.remove(self.session_file) - sys.exit(1) + except Exception as e: + logger.error(f"Failed to locate existing session.lock with error: {e} ") + console.error(f"Failed to locate existing session.lock with error: {e} ") + + sys.exit(1) pid = os.getpid() now = datetime.now() @@ -474,7 +509,7 @@ class Helpers: 'pid': pid, 'started': now.strftime("%d-%m-%Y, %H:%M:%S") } - with open(self.session_file, 'w') as f: + with open(self.session_file, 'w', encoding='utf-8') as f: json.dump(session_data, f, indent=True) # because this is a recursive function, we will return bytes, and set human readable later @@ -501,14 +536,14 @@ class Helpers: return sizes @staticmethod - def base64_encode_string(string: str): - s_bytes = str(string).encode('utf-8') + def base64_encode_string(fun_str: str): + s_bytes = str(fun_str).encode('utf-8') b64_bytes = base64.encodebytes(s_bytes) return b64_bytes.decode('utf-8') @staticmethod - def base64_decode_string(string: str): - s_bytes = str(string).encode('utf-8') + def base64_decode_string(fun_str: str): + s_bytes = str(fun_str).encode('utf-8') b64_bytes = base64.decodebytes(s_bytes) return b64_bytes.decode("utf-8") @@ -528,7 +563,7 @@ class Helpers: try: os.makedirs(path) - logger.debug("Created Directory : {}".format(path)) + logger.debug(f"Created Directory : {path}") # directory already exists - non-blocking error except FileExistsError: @@ -545,8 +580,8 @@ class Helpers: cert_file = os.path.join(cert_dir, 'commander.cert.pem') key_file = os.path.join(cert_dir, 'commander.key.pem') - logger.info("SSL Cert File is set to: {}".format(cert_file)) - logger.info("SSL Key File is set to: {}".format(key_file)) + logger.info(f"SSL Cert File is set to: {cert_file}") + logger.info(f"SSL Key File is set to: {key_file}") # don't create new files if we already have them. if self.check_file_exists(cert_file) and self.check_file_exists(key_file): @@ -577,11 +612,11 @@ class Helpers: cert.set_pubkey(k) cert.sign(k, 'sha256') - f = open(cert_file, "w") + f = open(cert_file, "w", encoding='utf-8') f.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode()) f.close() - f = open(key_file, "w") + f = open(key_file, "w", encoding='utf-8') f.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, k).decode()) f.close() @@ -601,12 +636,26 @@ class Helpers: else: return False + @staticmethod + def wtol_path(w_path): + l_path = w_path.replace('\\', '/') + return l_path + + @staticmethod + def ltow_path(l_path): + w_path = l_path.replace('/', '\\') + return w_path + + @staticmethod + def get_os_understandable_path(path): + return os.path.normpath(path) + def find_default_password(self): default_file = os.path.join(self.root_dir, "default.json") data = {} if self.check_file_exists(default_file): - with open(default_file, 'r') as f: + with open(default_file, 'r', encoding='utf-8') as f: data = json.load(f) del_json = helper.get_setting('delete_default_json') @@ -618,37 +667,169 @@ class Helpers: @staticmethod def generate_tree(folder, output=""): - for raw_filename in os.listdir(folder): + dir_list = [] + unsorted_files = [] + file_list = os.listdir(folder) + for item in file_list: + if os.path.isdir(os.path.join(folder, item)): + dir_list.append(item) + else: + unsorted_files.append(item) + file_list = sorted(dir_list, key=str.casefold) + sorted(unsorted_files, key=str.casefold) + for raw_filename in file_list: filename = html.escape(raw_filename) rel = os.path.join(folder, raw_filename) + dpath = os.path.join(folder, filename) + if os.path.isdir(rel): + output += \ + f"""
  • + \n
    + + + + {filename} + +
  • + \n"""\ + + else: + if filename != "crafty_managed.txt": + output += f"""
  • {filename}
  • """ + return output + + @staticmethod + def generate_dir(folder, output=""): + dir_list = [] + unsorted_files = [] + file_list = os.listdir(folder) + for item in file_list: + if os.path.isdir(os.path.join(folder, item)): + dir_list.append(item) + else: + unsorted_files.append(item) + file_list = sorted(dir_list, key=str.casefold) + sorted(unsorted_files, key=str.casefold) + output += \ + f"""
      """\ + + for raw_filename in file_list: + filename = html.escape(raw_filename) + dpath = os.path.join(folder, filename) + rel = os.path.join(folder, raw_filename) if os.path.isdir(rel): output += \ - """
    • - \n
      + f"""
    • + \n
      + - {} -
      - \n
        """\ - .format(os.path.join(folder, filename), os.path.join(folder, filename), filename, filename) + {filename} + +
      • """\ - output += helper.generate_tree(rel) - output += '
      \n
    • ' else: - output += """
    • {}
    • """.format(os.path.join(folder, filename), filename, filename) + if filename != "crafty_managed.txt": + output += f"""
    • {filename}
    • """ + output += '
    \n' return output + @staticmethod + def generate_zip_tree(folder, output=""): + file_list = os.listdir(folder) + file_list = sorted(file_list, key=str.casefold) + output += \ + f"""
      """\ + + for raw_filename in file_list: + filename = html.escape(raw_filename) + rel = os.path.join(folder, raw_filename) + dpath = os.path.join(folder, filename) + if os.path.isdir(rel): + output += \ + f"""
    • + \n
      + + + + + {filename} + +
    • + \n"""\ + + return output + + @staticmethod + def generate_zip_dir(folder, output=""): + file_list = os.listdir(folder) + file_list = sorted(file_list, key=str.casefold) + output += \ + f"""
        """\ + + for raw_filename in file_list: + filename = html.escape(raw_filename) + rel = os.path.join(folder, raw_filename) + dpath = os.path.join(folder, filename) + if os.path.isdir(rel): + output += \ + f"""
      • + \n
        + + + + + {filename} + +
      • """\ + + return output + + @staticmethod + def unzipServer(zip_path, user_id): + if helper.check_file_perms(zip_path): + tempDir = tempfile.mkdtemp() + with zipfile.ZipFile(zip_path, 'r') as zip_ref: + #extracts archive to temp directory + zip_ref.extractall(tempDir) + if user_id: + websocket_helper.broadcast_user(user_id, 'send_temp_path',{ + 'path': tempDir + }) + @staticmethod + def backup_select(path, user_id): + if user_id: + websocket_helper.broadcast_user(user_id, 'send_temp_path',{ + 'path': path + }) + + @staticmethod + def unzip_backup_archive(backup_path, zip_name): + zip_path = os.path.join(backup_path, zip_name) + if helper.check_file_perms(zip_path): + tempDir = tempfile.mkdtemp() + with zipfile.ZipFile(zip_path, 'r') as zip_ref: + #extracts archive to temp directory + zip_ref.extractall(tempDir) + return tempDir + else: + return False + @staticmethod def in_path(parent_path, child_path): # Smooth out relative path names, note: if you are concerned about symbolic links, you should use os.path.realpath too parent_path = os.path.abspath(parent_path) child_path = os.path.abspath(child_path) - # Compare the common path of the parent and child path with the common path of just the parent path. Using the commonpath method on just the parent path will regularise the path name in the same way as the comparison that deals with both paths, removing any trailing path separator + # Compare the common path of the parent and child path with the common path of just the parent path. + # Using the commonpath method on just the parent path will regularise the path name in the same way + # as the comparison that deals with both paths, removing any trailing path separator return os.path.commonpath([parent_path]) == os.path.commonpath([parent_path, child_path]) @staticmethod @@ -658,7 +839,7 @@ class Helpers: @staticmethod def copy_files(source, dest): if os.path.isfile(source): - shutil.copyfile(source, dest) + file_helper.copy_file(source, dest) logger.info("Copying jar %s to %s", source, dest) else: logger.info("Source jar does not exist.") @@ -688,4 +869,13 @@ class Helpers: return text[len(prefix):] return text + @staticmethod + def getLangPage(text): + lang = text.split("_")[0] + region = text.split("_")[1] + if region == 'EN': + return 'en' + else: + return lang+"-"+region + helper = Helpers() diff --git a/app/classes/shared/installer.py b/app/classes/shared/installer.py index e52191ea..3ec90d8a 100644 --- a/app/classes/shared/installer.py +++ b/app/classes/shared/installer.py @@ -1,7 +1,6 @@ import sys import subprocess - class install: @staticmethod @@ -21,5 +20,4 @@ class install: print("Crafty has installed it's dependencies, please restart Crafty") sys.exit(0) - -installer = install() \ No newline at end of file +installer = install() diff --git a/app/classes/shared/main_controller.py b/app/classes/shared/main_controller.py index c777fb0c..c3d5f0a1 100644 --- a/app/classes/shared/main_controller.py +++ b/app/classes/shared/main_controller.py @@ -1,32 +1,36 @@ import os +import pathlib +from pathlib import Path +import shutil import time import logging -import sys -import yaml -import asyncio -import shutil import tempfile -import zipfile -from distutils import dir_util +from typing import Union -from app.classes.shared.helpers import helper -from app.classes.shared.console import console - -#Importing Models -from app.classes.models.crafty_permissions import crafty_permissions, Enum_Permissions_Crafty -from app.classes.models.servers import servers_helper -#Importing Controllers from app.classes.controllers.crafty_perms_controller import Crafty_Perms_Controller from app.classes.controllers.management_controller import Management_Controller from app.classes.controllers.users_controller import Users_Controller from app.classes.controllers.roles_controller import Roles_Controller from app.classes.controllers.server_perms_controller import Server_Perms_Controller from app.classes.controllers.servers_controller import Servers_Controller - +from app.classes.models.server_permissions import Enum_Permissions_Server +from app.classes.models.users import helper_users +from app.classes.models.management import helpers_management +from app.classes.models.servers import servers_helper +from app.classes.shared.console import console +from app.classes.shared.helpers import helper from app.classes.shared.server import Server +from app.classes.shared.file_helpers import file_helper from app.classes.minecraft.server_props import ServerProps from app.classes.minecraft.serverjars import server_jar_obj from app.classes.minecraft.stats import Stats +from app.classes.web.websocket_helper import websocket_helper + +try: + from peewee import DoesNotExist + +except ModuleNotFoundError as err: + helper.auto_installer_fix(err) logger = logging.getLogger(__name__) @@ -44,7 +48,7 @@ class Controller: def check_server_loaded(self, server_id_to_check: int): - logger.info("Checking to see if we already registered {}".format(server_id_to_check)) + logger.info(f"Checking to see if we already registered {server_id_to_check}") for s in self.servers_list: known_server = s.get('server_id') @@ -52,7 +56,7 @@ class Controller: return False if known_server == server_id_to_check: - logger.info('skipping initialization of server {} because it is already loaded'.format(server_id_to_check)) + logger.info(f'skipping initialization of server {server_id_to_check} because it is already loaded') return True return False @@ -69,20 +73,18 @@ class Controller: continue # if this server path no longer exists - let's warn and bomb out - if not helper.check_path_exists(s['path']): - logger.warning("Unable to find server {} at path {}. Skipping this server".format(s['server_name'], - s['path'])) + if not helper.check_path_exists(helper.get_os_understandable_path(s['path'])): + logger.warning(f"Unable to find server {s['server_name']} at path {s['path']}. Skipping this server") - console.warning("Unable to find server {} at path {}. Skipping this server".format(s['server_name'], - s['path'])) + console.warning(f"Unable to find server {s['server_name']} at path {s['path']}. Skipping this server") continue - settings_file = os.path.join(s['path'], 'server.properties') + settings_file = os.path.join(helper.get_os_understandable_path(s['path']), 'server.properties') # if the properties file isn't there, let's warn if not helper.check_file_exists(settings_file): - logger.error("Unable to find {}. Skipping this server.".format(settings_file)) - console.error("Unable to find {}. Skipping this server.".format(settings_file)) + logger.error(f"Unable to find {settings_file}. Skipping this server.") + console.error(f"Unable to find {settings_file}. Skipping this server.") continue settings = ServerProps(settings_file) @@ -105,39 +107,103 @@ class Controller: self.refresh_server_settings(s['server_id']) - console.info("Loaded Server: ID {} | Name: {} | Autostart: {} | Delay: {} ".format( - s['server_id'], - s['server_name'], - s['auto_start'], - s['auto_start_delay'] - )) + console.info(f"Loaded Server: ID {s['server_id']}" + + f" | Name: {s['server_name']}" + + f" | Autostart: {s['auto_start']}" + + f" | Delay: {s['auto_start_delay']} ") def refresh_server_settings(self, server_id: int): server_obj = self.get_server_obj(server_id) server_obj.reload_server_settings() + @staticmethod + def check_system_user(): + if helper_users.get_user_id_by_name("system") is not None: + return True + else: + return False + + def set_project_root(self, root_dir): + self.project_root = root_dir + + + def package_support_logs(self, exec_user): + #pausing so on screen notifications can run for user + time.sleep(7) + websocket_helper.broadcast_user(exec_user['user_id'], 'notification', 'Preparing your support logs') + tempDir = tempfile.mkdtemp() + tempZipStorage = tempfile.mkdtemp() + full_temp = os.path.join(tempDir, 'support_logs') + os.mkdir(full_temp) + tempZipStorage = os.path.join(tempZipStorage, "support_logs") + crafty_path = os.path.join(full_temp, "crafty") + os.mkdir(crafty_path) + server_path = os.path.join(full_temp, "server") + os.mkdir(server_path) + if exec_user['superuser']: + auth_servers = self.servers.get_all_defined_servers() + else: + user_servers = self.servers.get_authorized_servers(int(exec_user['user_id'])) + auth_servers = [] + for server in user_servers: + if Enum_Permissions_Server.Logs in self.server_perms.get_user_id_permissions_list(exec_user['user_id'], server["server_id"]): + auth_servers.append(server) + else: + logger.info(f"Logs permission not available for server {server['server_name']}. Skipping.") + #we'll iterate through our list of log paths from auth servers. + for server in auth_servers: + final_path = os.path.join(server_path, str(server['server_name'])) + os.mkdir(final_path) + try: + file_helper.copy_file(server['log_path'], final_path) + except Exception as e: + logger.warning(f"Failed to copy file with error: {e}") + #Copy crafty logs to archive dir + full_log_name = os.path.join(crafty_path, 'logs') + file_helper.copy_dir(os.path.join(self.project_root, 'logs'), full_log_name) + file_helper.make_archive(tempZipStorage, tempDir) + + tempZipStorage += '.zip' + websocket_helper.broadcast_user(exec_user['user_id'], 'send_logs_bootbox', { + }) + + self.users.set_support_path(exec_user['user_id'], tempZipStorage) + + @staticmethod + def add_system_user(): + helper_users.add_user("system", helper.random_string_generator(64), "default@example.com", False, False) + def get_server_settings(self, server_id): for s in self.servers_list: if int(s['server_id']) == int(server_id): return s['server_settings'] - logger.warning("Unable to find server object for server id {}".format(server_id)) + logger.warning(f"Unable to find server object for server id {server_id}") return False - def get_server_obj(self, server_id): + def crash_detection(self, server_obj): + svr = self.get_server_obj(server_obj.server_id) + #start or stop crash detection depending upon user preference + #The below functions check to see if the server is running. They only execute if it's running. + if server_obj.crash_detection == 1: + svr.start_crash_detection() + else: + svr.stop_crash_detection() + + def get_server_obj(self, server_id: Union[str, int]) -> Union[bool, Server]: for s in self.servers_list: - if int(s['server_id']) == int(server_id): + if str(s['server_id']) == str(server_id): return s['server_obj'] - logger.warning("Unable to find server object for server id {}".format(server_id)) - return False + logger.warning(f"Unable to find server object for server id {server_id}") + return False # TODO: Change to None - def get_server_data(self, server_id): + def get_server_data(self, server_id: str): for s in self.servers_list: - if int(s['server_id']) == int(server_id): + if str(s['server_id']) == str(server_id): return s['server_data_obj'] - logger.warning("Unable to find server object for server id {}".format(server_id)) + logger.warning(f"Unable to find server object for server id {server_id}") return False @staticmethod @@ -165,15 +231,15 @@ class Controller: def stop_all_servers(self): servers = self.list_running_servers() - logger.info("Found {} running server(s)".format(len(servers))) - console.info("Found {} running server(s)".format(len(servers))) + logger.info(f"Found {len(servers)} running server(s)") + console.info(f"Found {len(servers)} running server(s)") logger.info("Stopping All Servers") console.info("Stopping All Servers") for s in servers: - logger.info("Stopping Server ID {} - {}".format(s['id'], s['name'])) - console.info("Stopping Server ID {} - {}".format(s['id'], s['name'])) + logger.info(f"Stopping Server ID {s['id']} - {s['name']}") + console.info(f"Stopping Server ID {s['id']} - {s['name']}") self.stop_server(s['id']) @@ -185,14 +251,20 @@ class Controller: def stop_server(self, server_id): # issue the stop command + svr_obj = self.get_server_obj(server_id) svr_obj.stop_threaded_server() def create_jar_server(self, server: str, version: str, name: str, min_mem: int, max_mem: int, port: int): server_id = helper.create_uuid() server_dir = os.path.join(helper.servers_dir, server_id) backup_path = os.path.join(helper.backup_path, server_id) + if helper.is_os_windows(): + server_dir = helper.wtol_path(server_dir) + backup_path = helper.wtol_path(backup_path) + server_dir.replace(' ', '^ ') + backup_path.replace(' ', '^ ') - server_file = "{server}-{version}.jar".format(server=server, version=version) + server_file = f"{server}-{version}.jar" full_jar_path = os.path.join(server_dir, server_file) # make the dir - perhaps a UUID? @@ -201,33 +273,38 @@ class Controller: try: # do a eula.txt - with open(os.path.join(server_dir, "eula.txt"), 'w') as f: + with open(os.path.join(server_dir, "eula.txt"), 'w', encoding='utf-8') as f: f.write("eula=false") f.close() # setup server.properties with the port - with open(os.path.join(server_dir, "server.properties"), "w") as f: - f.write("server-port={}".format(port)) + with open(os.path.join(server_dir, "server.properties"), "w", encoding='utf-8') as f: + f.write(f"server-port={port}") f.close() except Exception as e: - logger.error("Unable to create required server files due to :{}".format(e)) + logger.error(f"Unable to create required server files due to :{e}") return False - server_command = 'java -Xms{}M -Xmx{}M -jar {} nogui'.format(helper.float_to_string(min_mem), - helper.float_to_string(max_mem), - full_jar_path) - server_log_file = "{}/logs/latest.log".format(server_dir) + #must remain non-fstring due to string addtion + if helper.is_os_windows(): + server_command = f'java -Xms{helper.float_to_string(min_mem)}M -Xmx{helper.float_to_string(max_mem)}M -jar "{full_jar_path}" nogui' + else: + server_command = f'java -Xms{helper.float_to_string(min_mem)}M -Xmx{helper.float_to_string(max_mem)}M -jar {full_jar_path} nogui' + server_log_file = f"{server_dir}/logs/latest.log" server_stop = "stop" - # download the jar - server_jar_obj.download_jar(server, version, full_jar_path, name) + new_id = self.register_server(name, server_id, server_dir, backup_path, server_command, server_file, server_log_file, server_stop, + port, server_type='minecraft-java') + + # download the jar + server_jar_obj.download_jar(server, version, full_jar_path, new_id) - new_id = self.register_server(name, server_id, server_dir, backup_path, server_command, server_file, server_log_file, server_stop) return new_id @staticmethod def verify_jar_server( server_path: str, server_jar: str): + server_path = helper.get_os_understandable_path(server_path) path_check = helper.check_path_exists(server_path) jar_check = helper.check_file_exists(os.path.join(server_path, server_jar)) if not path_check or not jar_check: @@ -236,6 +313,7 @@ class Controller: @staticmethod def verify_zip_server(zip_path: str): + zip_path = helper.get_os_understandable_path(zip_path) zip_check = helper.check_file_exists(zip_path) if not zip_check: return False @@ -245,88 +323,239 @@ class Controller: server_id = helper.create_uuid() new_server_dir = os.path.join(helper.servers_dir, server_id) backup_path = os.path.join(helper.backup_path, server_id) + if helper.is_os_windows(): + new_server_dir = helper.wtol_path(new_server_dir) + backup_path = helper.wtol_path(backup_path) + new_server_dir.replace(' ', '^ ') + backup_path.replace(' ', '^ ') helper.ensure_dir_exists(new_server_dir) helper.ensure_dir_exists(backup_path) - dir_util.copy_tree(server_path, new_server_dir) + server_path = helper.get_os_understandable_path(server_path) + try: + file_helper.copy_dir(server_path, new_server_dir, True) + except shutil.Error as ex: + logger.error(f"Server import failed with error: {ex}") + + has_properties = False + for item in os.listdir(new_server_dir): + if str(item) == 'server.properties': + has_properties = True + if not has_properties: + logger.info(f"No server.properties found on zip file import. Creating one with port selection of {str(port)}") + with open(os.path.join(new_server_dir, "server.properties"), "w", encoding='utf-8') as f: + f.write(f"server-port={port}") + f.close() full_jar_path = os.path.join(new_server_dir, server_jar) - server_command = 'java -Xms{}M -Xmx{}M -jar {} nogui'.format(helper.float_to_string(min_mem), - helper.float_to_string(max_mem), - full_jar_path) - server_log_file = "{}/logs/latest.log".format(new_server_dir) + + #due to adding strings this must not be an fstring + if helper.is_os_windows(): + server_command = f'java -Xms{helper.float_to_string(min_mem)}M -Xmx{helper.float_to_string(max_mem)}M -jar "{full_jar_path}" nogui' + else: + server_command = f'java -Xms{helper.float_to_string(min_mem)}M -Xmx{helper.float_to_string(max_mem)}M -jar {full_jar_path} nogui' + server_log_file = f"{new_server_dir}/logs/latest.log" server_stop = "stop" new_id = self.register_server(server_name, server_id, new_server_dir, backup_path, server_command, server_jar, - server_log_file, server_stop, port) + server_log_file, server_stop, port, server_type='minecraft-java') return new_id def import_zip_server(self, server_name: str, zip_path: str, server_jar: str, min_mem: int, max_mem: int, port: int): server_id = helper.create_uuid() new_server_dir = os.path.join(helper.servers_dir, server_id) backup_path = os.path.join(helper.backup_path, server_id) + if helper.is_os_windows(): + new_server_dir = helper.wtol_path(new_server_dir) + backup_path = helper.wtol_path(backup_path) + new_server_dir.replace(' ', '^ ') + backup_path.replace(' ', '^ ') - if helper.check_file_perms(zip_path): - helper.ensure_dir_exists(new_server_dir) - helper.ensure_dir_exists(backup_path) - tempDir = tempfile.mkdtemp() - with zipfile.ZipFile(zip_path, 'r') as zip_ref: - zip_ref.extractall(tempDir) - for i in range(len(zip_ref.filelist)): - if len(zip_ref.filelist) > 1 or not zip_ref.filelist[i].filename.endswith('/'): - test = zip_ref.filelist[i].filename - break - path_list = test.split('/') - root_path = path_list[0] - if len(path_list) > 1: - for i in range(len(path_list)-2): - root_path = os.path.join(root_path, path_list[i+1]) - - full_root_path = os.path.join(tempDir, root_path) - - has_properties = False - for item in os.listdir(full_root_path): - if str(item) == 'server.properties': - has_properties = True - try: - shutil.move(os.path.join(full_root_path, item), os.path.join(new_server_dir, item)) - except Exception as ex: - logger.error('ERROR IN ZIP IMPORT: {}'.format(ex)) - if not has_properties: - logger.info("No server.properties found on zip file import. Creating one with port selection of {}".format(str(port))) - with open(os.path.join(new_server_dir, "server.properties"), "w") as f: - f.write("server-port={}".format(port)) - f.close() - zip_ref.close() - else: - return "false" + tempDir = helper.get_os_understandable_path(zip_path) + helper.ensure_dir_exists(new_server_dir) + helper.ensure_dir_exists(backup_path) + has_properties = False + #extracts archive to temp directory + for item in os.listdir(tempDir): + if str(item) == 'server.properties': + has_properties = True + try: + if not os.path.isdir(os.path.join(tempDir, item)): + file_helper.move_file(os.path.join(tempDir, item), os.path.join(new_server_dir, item)) + else: + file_helper.move_dir(os.path.join(tempDir, item), os.path.join(new_server_dir, item)) + except Exception as ex: + logger.error(f'ERROR IN ZIP IMPORT: {ex}') + if not has_properties: + logger.info(f"No server.properties found on zip file import. Creating one with port selection of {str(port)}") + with open(os.path.join(new_server_dir, "server.properties"), "w", encoding='utf-8') as f: + f.write(f"server-port={port}") + f.close() full_jar_path = os.path.join(new_server_dir, server_jar) - server_command = 'java -Xms{}M -Xmx{}M -jar {} nogui'.format(helper.float_to_string(min_mem), - helper.float_to_string(max_mem), - full_jar_path) + + #due to strings being added we need to leave this as not an fstring + if helper.is_os_windows(): + server_command = f'java -Xms{helper.float_to_string(min_mem)}M -Xmx{helper.float_to_string(max_mem)}M -jar "{full_jar_path}" nogui' + else: + server_command = f'java -Xms{helper.float_to_string(min_mem)}M -Xmx{helper.float_to_string(max_mem)}M -jar {full_jar_path} nogui' logger.debug('command: ' + server_command) - server_log_file = "{}/logs/latest.log".format(new_server_dir) + server_log_file = f"{new_server_dir}/logs/latest.log" server_stop = "stop" new_id = self.register_server(server_name, server_id, new_server_dir, backup_path, server_command, server_jar, - server_log_file, server_stop, port) + server_log_file, server_stop, port, server_type='minecraft-java') return new_id - def register_server(self, name: str, server_uuid: str, server_dir: str, backup_path: str, server_command: str, server_file: str, server_log_file: str, server_stop: str, server_port=25565): - # put data in the db - new_id = self.servers.create_server(name, server_uuid, server_dir, backup_path, server_command, server_file, server_log_file, server_stop, server_port) + #************************************************************************************************ + # BEDROCK IMPORTS + #************************************************************************************************ + def import_bedrock_server(self, server_name: str, server_path: str, server_exe: str, port: int): + server_id = helper.create_uuid() + new_server_dir = os.path.join(helper.servers_dir, server_id) + backup_path = os.path.join(helper.backup_path, server_id) + if helper.is_os_windows(): + new_server_dir = helper.wtol_path(new_server_dir) + backup_path = helper.wtol_path(backup_path) + new_server_dir.replace(' ', '^ ') + backup_path.replace(' ', '^ ') + + helper.ensure_dir_exists(new_server_dir) + helper.ensure_dir_exists(backup_path) + server_path = helper.get_os_understandable_path(server_path) try: - # place a file in the dir saying it's owned by crafty - with open(os.path.join(server_dir, "crafty_managed.txt"), 'w') as f: - f.write( - "The server is managed by Crafty Controller.\n Leave this directory/files alone please") + file_helper.copy_dir(server_path, new_server_dir, True) + except shutil.Error as ex: + logger.error(f"Server import failed with error: {ex}") + + has_properties = False + for item in os.listdir(new_server_dir): + if str(item) == 'server.properties': + has_properties = True + if not has_properties: + logger.info(f"No server.properties found on zip file import. Creating one with port selection of {str(port)}") + with open(os.path.join(new_server_dir, "server.properties"), "w", encoding='utf-8') as f: + f.write(f"server-port={port}") f.close() - except Exception as e: - logger.error("Unable to create required server files due to :{}".format(e)) - return False + full_jar_path = os.path.join(new_server_dir, server_exe) + + #due to adding strings this must not be an fstring + if helper.is_os_windows(): + server_command = f'"{full_jar_path}"' + else: + server_command = f'./{server_exe}' + logger.debug('command: ' + server_command) + server_log_file = "N/A" + server_stop = "stop" + + new_id = self.register_server(server_name, server_id, new_server_dir, backup_path, server_command, server_exe, + server_log_file, server_stop, port, server_type='minecraft-bedrock') + if os.name != "nt": + if helper.check_file_exists(full_jar_path): + os.chmod(full_jar_path, 0o2775) + return new_id + + def import_bedrock_zip_server(self, server_name: str, zip_path: str, server_exe: str, port: int): + server_id = helper.create_uuid() + new_server_dir = os.path.join(helper.servers_dir, server_id) + backup_path = os.path.join(helper.backup_path, server_id) + if helper.is_os_windows(): + new_server_dir = helper.wtol_path(new_server_dir) + backup_path = helper.wtol_path(backup_path) + new_server_dir.replace(' ', '^ ') + backup_path.replace(' ', '^ ') + + tempDir = helper.get_os_understandable_path(zip_path) + helper.ensure_dir_exists(new_server_dir) + helper.ensure_dir_exists(backup_path) + has_properties = False + #extracts archive to temp directory + for item in os.listdir(tempDir): + if str(item) == 'server.properties': + has_properties = True + try: + if not os.path.isdir(os.path.join(tempDir, item)): + file_helper.move_file(os.path.join(tempDir, item), os.path.join(new_server_dir, item)) + else: + file_helper.move_dir(os.path.join(tempDir, item), os.path.join(new_server_dir, item)) + except Exception as ex: + logger.error(f'ERROR IN ZIP IMPORT: {ex}') + if not has_properties: + logger.info(f"No server.properties found on zip file import. Creating one with port selection of {str(port)}") + with open(os.path.join(new_server_dir, "server.properties"), "w", encoding='utf-8') as f: + f.write(f"server-port={port}") + f.close() + + full_jar_path = os.path.join(new_server_dir, server_exe) + + #due to strings being added we need to leave this as not an fstring + if helper.is_os_windows(): + server_command = f'"{full_jar_path}"' + else: + server_command = f'./{server_exe}' + logger.debug('command: ' + server_command) + server_log_file = "N/A" + server_stop = "stop" + + new_id = self.register_server(server_name, server_id, new_server_dir, backup_path, server_command, server_exe, + server_log_file, server_stop, port, server_type='minecraft-bedrock') + if os.name != "nt": + if helper.check_file_exists(full_jar_path): + os.chmod(full_jar_path, 0o2775) + + return new_id + + #************************************************************************************************ + # BEDROCK IMPORTS END + #************************************************************************************************ + + def rename_backup_dir(self, old_server_id, new_server_id, new_uuid): + server_data = self.servers.get_server_data_by_id(old_server_id) + old_bu_path = server_data['backup_path'] + Server_Perms_Controller.backup_role_swap(old_server_id, new_server_id) + if not helper.is_os_windows(): + backup_path = helper.validate_traversal(helper.backup_path, old_bu_path) + if helper.is_os_windows(): + backup_path = helper.validate_traversal(helper.wtol_path(helper.backup_path), helper.wtol_path(old_bu_path)) + backup_path = helper.wtol_path(str(backup_path)) + backup_path.replace(' ', '^ ') + backup_path = Path(backup_path) + backup_path_components = list(backup_path.parts) + backup_path_components[-1] = new_uuid + new_bu_path = pathlib.PurePath(os.path.join(*backup_path_components)) + if os.path.isdir(new_bu_path): + if helper.validate_traversal(helper.backup_path, new_bu_path): + os.rmdir(new_bu_path) + backup_path.rename(new_bu_path) + + def register_server(self, name: str, + server_uuid: str, + server_dir: str, + backup_path: str, + server_command: str, + server_file: str, + server_log_file: str, + server_stop: str, + server_port: int, + server_type: str): + # put data in the db + + new_id = self.servers.create_server( + name, server_uuid, server_dir, backup_path, server_command, server_file, server_log_file, server_stop, server_type, server_port) + + if not helper.check_file_exists(os.path.join(server_dir, "crafty_managed.txt")): + try: + # place a file in the dir saying it's owned by crafty + with open(os.path.join(server_dir, "crafty_managed.txt"), 'w', encoding='utf-8') as f: + f.write( + "The server is managed by Crafty Controller.\n Leave this directory/files alone please") + f.close() + + except Exception as e: + logger.error(f"Unable to create required server files due to :{e}") + return False # let's re-init all servers self.init_all_servers() @@ -338,12 +567,12 @@ class Controller: for s in self.servers_list: # if this is the droid... im mean server we are looking for... - if int(s['server_id']) == int(server_id): + if str(s['server_id']) == str(server_id): server_data = self.get_server_data(server_id) server_name = server_data['server_name'] - logger.info("Deleting Server: ID {} | Name: {} ".format(server_id, server_name)) - console.info("Deleting Server: ID {} | Name: {} ".format(server_id, server_name)) + logger.info(f"Deleting Server: ID {server_id} | Name: {server_name} ") + console.info(f"Deleting Server: ID {server_id} | Name: {server_name} ") srv_obj = s['server_obj'] running = srv_obj.check_running() @@ -351,7 +580,19 @@ class Controller: if running: self.stop_server(server_id) if files: - shutil.rmtree(self.servers.get_server_data_by_id(server_id)['path']) + try: + file_helper.del_dirs(helper.get_os_understandable_path(self.servers.get_server_data_by_id(server_id)['path'])) + except Exception as e: + logger.error(f"Unable to delete server files for server with ID: {server_id} with error logged: {e}") + if helper.check_path_exists(self.servers.get_server_data_by_id(server_id)['backup_path']): + file_helper.del_dirs(helper.get_os_understandable_path(self.servers.get_server_data_by_id(server_id)['backup_path'])) + + + #Cleanup scheduled tasks + try: + helpers_management.delete_scheduled_task_by_server(server_id) + except DoesNotExist: + logger.info("No scheduled jobs exist. Continuing.") # remove the server from the DB self.servers.remove_server(server_id) @@ -359,3 +600,6 @@ class Controller: self.servers_list.pop(counter) counter += 1 + @staticmethod + def clear_unexecuted_commands(): + helpers_management.clear_unexecuted_commands() diff --git a/app/classes/shared/main_models.py b/app/classes/shared/main_models.py index 596fa372..eee69f82 100644 --- a/app/classes/shared/main_models.py +++ b/app/classes/shared/main_models.py @@ -1,31 +1,25 @@ -import os -import sys import logging -import datetime +from app.classes.models.users import Users, users_helper from app.classes.shared.helpers import helper from app.classes.shared.console import console -from app.classes.models.users import Users, users_helper -from app.classes.minecraft.server_props import ServerProps -from app.classes.web.websocket_helper import websocket_helper +# To disable warning about unused import ; Users is imported from here in other places +# pylint: disable=self-assigning-variable +Users = Users + +try: + # pylint: disable=unused-import + from peewee import SqliteDatabase, fn + from playhouse.shortcuts import model_to_dict + +except ModuleNotFoundError as err: + helper.auto_installer_fix(err) logger = logging.getLogger(__name__) peewee_logger = logging.getLogger('peewee') peewee_logger.setLevel(logging.INFO) - -try: - from peewee import * - from playhouse.shortcuts import model_to_dict - from enum import Enum - import yaml - -except ModuleNotFoundError as e: - logger.critical("Import Error: Unable to load {} module".format(e.name), exc_info=True) - console.critical("Import Error: Unable to load {} module".format(e.name)) - sys.exit(1) - -database = SqliteDatabase(helper.db_path, pragmas={ +database = SqliteDatabase(helper.db_path, pragmas = { 'journal_mode': 'wal', 'cache_size': -1024 * 10}) @@ -39,28 +33,17 @@ class db_builder: username = default_data.get("username", 'admin') password = default_data.get("password", 'crafty') - #api_token = helper.random_string_generator(32) - # - #Users.insert({ - # Users.username: username.lower(), - # Users.password: helper.encode_pass(password), - # Users.api_token: api_token, - # Users.enabled: True, - # Users.superuser: True - #}).execute() - user_id = users_helper.add_user(username=username, password=password, superuser=True) - #users_helper.update_user(user_id, user_crafty_data={"permissions_mask":"111", "server_quantity":[-1,-1,-1]} ) - #console.info("API token is {}".format(api_token)) + users_helper.add_user(username=username, password=password, email="default@example.com", superuser=True) @staticmethod def is_fresh_install(): try: user = users_helper.get_by_id(1) - return False + if user: + return False except: return True - pass class db_shortcuts: @@ -76,8 +59,7 @@ class db_shortcuts: for s in query: rows.append(model_to_dict(s)) except Exception as e: - logger.warning("Database Error: {}".format(e)) - pass + logger.warning(f"Database Error: {e}") return rows @@ -85,10 +67,10 @@ class db_shortcuts: def return_db_rows(model): data = [model_to_dict(row) for row in model] return data - + #************************************************************************************************ -# Static Accessors +# Static Accessors #************************************************************************************************ installer = db_builder() db_helper = db_shortcuts() diff --git a/app/classes/shared/migration.py b/app/classes/shared/migration.py index 9a9d821e..4105b833 100644 --- a/app/classes/shared/migration.py +++ b/app/classes/shared/migration.py @@ -1,536 +1,447 @@ -from datetime import datetime -import logging -import typing as t -import sys -import os -import re -from importlib import import_module -from functools import wraps - -try: - from functools import cached_property -except ImportError: - from cached_property import cached_property - -from app.classes.shared.helpers import helper -from app.classes.shared.console import console - -logger = logging.getLogger(__name__) - -try: - import peewee - from playhouse.migrate import ( - SchemaMigrator as ScM, - SqliteMigrator as SqM, - Operation, SQL, operation, SqliteDatabase, - make_index_name, Context - ) - -except ModuleNotFoundError as e: - logger.critical("Import Error: Unable to load {} module".format( - e.name), exc_info=True) - console.critical("Import Error: Unable to load {} module".format(e.name)) - sys.exit(1) - - -class MigrateHistory(peewee.Model): - """ - Presents the migration history in a database. - """ - - name = peewee.CharField(unique=True) - migrated_at = peewee.DateTimeField(default=datetime.utcnow) - - def __unicode__(self) -> str: - """ - String representation of this migration - """ - return self.name - - -MIGRATE_TABLE = 'migratehistory' -MIGRATE_TEMPLATE = '''# Generated by database migrator - - -def migrate(migrator, database, **kwargs): - """ - Write your migrations here. - """ -{migrate} - - -def rollback(migrator, database, **kwargs): - """ - Write your rollback migrations here. - """ -{rollback}''' -VOID: t.Callable = lambda m, d: None - - -def get_model(method): - """ - Convert string to model class. - """ - - @wraps(method) - def wrapper(migrator, model, *args, **kwargs): - if isinstance(model, str): - return method(migrator, migrator.orm[model], *args, **kwargs) - return method(migrator, model, *args, **kwargs) - return wrapper - - -class Migrator(object): - def __init__(self, database: t.Union[peewee.Database, peewee.Proxy]): - """ - Initializes the migrator - """ - if isinstance(database, peewee.Proxy): - database = database.obj - self.database: SqliteDatabase = database - self.orm: t.Dict[str, peewee.Model] = {} - self.operations: t.List[Operation] = [] - self.migrator = SqliteMigrator(database) - - def run(self): - """ - Runs operations. - """ - for op in self.operations: - if isinstance(op, Operation): - op.run() - else: - op() - self.clean() - - def clean(self): - """ - Cleans the operations. - """ - self.operations = list() - - def sql(self, sql: str, *params): - """ - Executes raw SQL. - """ - self.operations.append(self.migrator.sql(sql, *params)) - - def create_table(self, model: peewee.Model) -> peewee.Model: - """ - Creates model and table in database. - """ - self.orm[model._meta.table_name] = model - model._meta.database = self.database - self.operations.append(model.create_table) - return model - - @get_model - def drop_table(self, model: peewee.Model): - """ - Drops model and table from database. - """ - del self.orm[model._meta.table_name] - self.operations.append(self.migrator.drop_table(model)) - - @get_model - def add_columns(self, model: peewee.Model, **fields: peewee.Field) -> peewee.Model: - """ - Creates new fields. - """ - for name, field in fields.items(): - model._meta.add_field(name, field) - self.operations.append(self.migrator.add_column( - model._meta.table_name, field.column_name, field)) - if field.unique: - self.operations.append(self.migrator.add_index( - model._meta.table_name, (field.column_name,), unique=True)) - return model - - @get_model - def change_columns(self, model: peewee.Model, **fields: peewee.Field) -> peewee.Model: - """ - Changes fields. - """ - for name, field in fields.items(): - old_field = model._meta.fields.get(name, field) - old_column_name = old_field and old_field.column_name - - model._meta.add_field(name, field) - - if isinstance(old_field, peewee.ForeignKeyField): - self.operations.append(self.migrator.drop_foreign_key_constraint( - model._meta.table_name, old_column_name)) - - if old_column_name != field.column_name: - self.operations.append( - self.migrator.rename_column( - model._meta.table_name, old_column_name, field.column_name)) - - if isinstance(field, peewee.ForeignKeyField): - on_delete = field.on_delete if field.on_delete else 'RESTRICT' - on_update = field.on_update if field.on_update else 'RESTRICT' - self.operations.append(self.migrator.add_foreign_key_constraint( - model._meta.table_name, field.column_name, - field.rel_model._meta.table_name, field.rel_field.name, - on_delete, on_update)) - continue - - self.operations.append(self.migrator.change_column( - model._meta.table_name, field.column_name, field)) - - if field.unique == old_field.unique: - continue - - if field.unique: - index = (field.column_name,), field.unique - self.operations.append(self.migrator.add_index( - model._meta.table_name, *index)) - model._meta.indexes.append(index) - else: - index = (field.column_name,), old_field.unique - self.operations.append(self.migrator.drop_index( - model._meta.table_name, *index)) - model._meta.indexes.remove(index) - - return model - - @get_model - def drop_columns(self, model: peewee.Model, names: str, **kwargs) -> peewee.Model: - """ - Removes fields from model. - """ - fields = [field for field in model._meta.fields.values() - if field.name in names] - cascade = kwargs.pop('cascade', True) - for field in fields: - self.__del_field__(model, field) - if field.unique: - index_name = make_index_name( - model._meta.table_name, [field.column_name]) - self.operations.append(self.migrator.drop_index( - model._meta.table_name, index_name)) - self.operations.append( - self.migrator.drop_column( - model._meta.table_name, field.column_name, cascade=False)) - return model - - def __del_field__(self, model: peewee.Model, field: peewee.Field): - """ - Deletes field from model. - """ - model._meta.remove_field(field.name) - delattr(model, field.name) - if isinstance(field, peewee.ForeignKeyField): - obj_id_name = field.column_name - if field.column_name == field.name: - obj_id_name += '_id' - delattr(model, obj_id_name) - delattr(field.rel_model, field.backref) - - @get_model - def rename_column(self, model: peewee.Model, old_name: str, new_name: str) -> peewee.Model: - """ - Renames field in model. - """ - field = model._meta.fields[old_name] - if isinstance(field, peewee.ForeignKeyField): - old_name = field.column_name - self.__del_field__(model, field) - field.name = field.column_name = new_name - model._meta.add_field(new_name, field) - if isinstance(field, peewee.ForeignKeyField): - field.column_name = new_name = field.column_name + '_id' - self.operations.append(self.migrator.rename_column( - model._meta.table_name, old_name, new_name)) - return model - - @get_model - def rename_table(self, model: peewee.Model, new_name: str) -> peewee.Model: - """ - Renames table in database. - """ - old_name = model._meta.table_name - del self.orm[model._meta.table_name] - model._meta.table_name = new_name - self.orm[model._meta.table_name] = model - self.operations.append(self.migrator.rename_table(old_name, new_name)) - return model - - @get_model - def add_index(self, model: peewee.Model, *columns: str, **kwargs) -> peewee.Model: - """Create indexes.""" - unique = kwargs.pop('unique', False) - model._meta.indexes.append((columns, unique)) - columns_ = [] - for col in columns: - field = model._meta.fields.get(col) - - if len(columns) == 1: - field.unique = unique - field.index = not unique - - if isinstance(field, peewee.ForeignKeyField): - col = col + '_id' - - columns_.append(col) - self.operations.append(self.migrator.add_index( - model._meta.table_name, columns_, unique=unique)) - return model - - @get_model - def drop_index(self, model: peewee.Model, *columns: str) -> peewee.Model: - """Drop indexes.""" - columns_ = [] - for col in columns: - field = model._meta.fields.get(col) - if not field: - continue - - if len(columns) == 1: - field.unique = field.index = False - - if isinstance(field, peewee.ForeignKeyField): - col = col + '_id' - columns_.append(col) - index_name = make_index_name(model._meta.table_name, columns_) - model._meta.indexes = [(cols, _) for ( - cols, _) in model._meta.indexes if columns != cols] - self.operations.append(self.migrator.drop_index( - model._meta.table_name, index_name)) - return model - - @get_model - def add_not_null(self, model: peewee.Model, *names: str) -> peewee.Model: - """Add not null.""" - for name in names: - field = model._meta.fields[name] - field.null = False - self.operations.append(self.migrator.add_not_null( - model._meta.table_name, field.column_name)) - return model - - @get_model - def drop_not_null(self, model: peewee.Model, *names: str) -> peewee.Model: - """Drop not null.""" - for name in names: - field = model._meta.fields[name] - field.null = True - self.operations.append(self.migrator.drop_not_null( - model._meta.table_name, field.column_name)) - return model - - @get_model - def add_default(self, model: peewee.Model, name: str, default: t.Any) -> peewee.Model: - """Add default.""" - field = model._meta.fields[name] - model._meta.defaults[field] = field.default = default - self.operations.append(self.migrator.apply_default( - model._meta.table_name, name, field)) - return model - - -class SqliteMigrator(SqM): - def drop_table(self, model): - return lambda: model.drop_table(cascade=False) - - @operation - def change_column(self, table: str, column_name: str, field: peewee.Field): - operations = [self.alter_change_column(table, column_name, field)] - if not field.null: - operations.extend([self.add_not_null(table, column_name)]) - return operations - - def alter_change_column(self, table: str, column_name: str, field: peewee.Field) -> Operation: - return self._update_column(table, column_name, lambda x, y: y) - - @operation - def sql(self, sql: str, *params) -> SQL: - """ - Executes raw SQL. - """ - return SQL(sql, *params) - - def alter_add_column( - self, table: str, column_name: str, field: peewee.Field, **kwargs) -> Operation: - """ - Fixes field name for ForeignKeys. - """ - name = field.name - op = super().alter_add_column( - table, column_name, field, **kwargs) - if isinstance(field, peewee.ForeignKeyField): - field.name = name - return op - - -class MigrationManager(object): - - filemask = re.compile(r"[\d]+_[^\.]+\.py$") - - def __init__(self, database: t.Union[peewee.Database, peewee.Proxy]): - """ - Initializes the migration manager. - """ - if not isinstance(database, (peewee.Database, peewee.Proxy)): - raise RuntimeError('Invalid database: {}'.format(database)) - self.database = database - - @cached_property - def model(self) -> peewee.Model: - """ - Initialize and cache the MigrationHistory model. - """ - MigrateHistory._meta.database = self.database - MigrateHistory._meta.table_name = 'migratehistory' - MigrateHistory._meta.schema = None - MigrateHistory.create_table(True) - return MigrateHistory - - @property - def done(self) -> t.List[str]: - """ - Scans migrations in the database. - """ - return [mm.name for mm in self.model.select().order_by(self.model.id)] - - @property - def todo(self): - """ - Scans migrations in the file system. - """ - if not os.path.exists(helper.migration_dir): - logger.warning('Migration directory: {} does not exist.'.format( - helper.migration_dir)) - os.makedirs(helper.migration_dir) - return sorted(f[:-3] for f in os.listdir(helper.migration_dir) if self.filemask.match(f)) - - @property - def diff(self) -> t.List[str]: - """ - Calculates difference between the filesystem and the database. - """ - done = set(self.done) - return [name for name in self.todo if name not in done] - - @cached_property - def migrator(self) -> Migrator: - """ - Create migrator and setup it with fake migrations. - """ - migrator = Migrator(self.database) - for name in self.done: - self.up_one(name, migrator, True) - return migrator - - def compile(self, name, migrate='', rollback=''): - """ - Compiles a migration. - """ - name = datetime.utcnow().strftime('%Y%m%d%H%M%S') + '_' + name - filename = name + '.py' - path = os.path.join(helper.migration_dir, filename) - with open(path, 'w') as f: - f.write(MIGRATE_TEMPLATE.format( - migrate=migrate, rollback=rollback, name=filename)) - - return name - - def create(self, name: str = 'auto', auto: bool = False) -> t.Optional[str]: - """ - Creates a migration. - """ - migrate = rollback = '' - if auto: - raise NotImplementedError - - logger.info('Creating migration "{}"'.format(name)) - name = self.compile(name, migrate, rollback) - logger.info('Migration has been created as "{}"'.format(name)) - return name - - def clear(self): - """Clear migrations.""" - self.model.delete().execute() - - def up(self, name: t.Optional[str] = None): - """ - Runs all unapplied migrations. - """ - logger.info('Starting migrations') - console.info('Starting migrations') - - done = [] - diff = self.diff - if not diff: - logger.info('There is nothing to migrate') - console.info('There is nothing to migrate') - return done - - migrator = self.migrator - for mname in diff: - done.append(self.up_one(mname, self.migrator)) - if name and name == mname: - break - - return done - - def read(self, name: str): - """ - Reads a migration from a file. - """ - call_params = dict() - if os.name == 'nt' and sys.version_info >= (3, 0): - # if system is windows - force utf-8 encoding - call_params['encoding'] = 'utf-8' - with open(os.path.join(helper.migration_dir, name + '.py'), **call_params) as f: - code = f.read() - scope = {} - code = compile(code, '', 'exec', dont_inherit=True) - exec(code, scope, None) - return scope.get('migrate', VOID), scope.get('rollback', VOID) - - def up_one(self, name: str, migrator: Migrator, - fake: bool = False, rollback: bool = False) -> str: - """ - Runs a migration with a given name. - """ - try: - migrate_fn, rollback_fn = self.read(name) - if fake: - migrate_fn(migrator, self.database) - migrator.clean() - return name - with self.database.transaction(): - if rollback: - logger.info('Rolling back "{}"'.format(name)) - rollback_fn(migrator, self.database) - migrator.run() - self.model.delete().where(self.model.name == name).execute() - else: - logger.info('Migrate "{}"'.format(name)) - migrate_fn(migrator, self.database) - migrator.run() - if name not in self.done: - self.model.create(name=name) - - logger.info('Done "{}"'.format(name)) - return name - - except Exception: - self.database.rollback() - operation = 'Rollback' if rollback else 'Migration' - logger.exception('{} failed: {}'.format(operation, name)) - raise - - def down(self, name: t.Optional[str] = None): - """ - Rolls back migrations. - """ - if not self.done: - raise RuntimeError('No migrations are found.') - - name = self.done[-1] - - migrator = self.migrator - self.up_one(name, migrator, False, True) - logger.warning('Rolled back migration: {}'.format(name)) +# pylint: skip-file +from datetime import datetime +import logging +import typing as t +import sys +import os +import re +from functools import wraps +from functools import cached_property + +from app.classes.shared.helpers import helper +from app.classes.shared.console import console + +try: + import peewee + from playhouse.migrate import ( + SqliteMigrator, + Operation, SQL, SqliteDatabase, + make_index_name + ) + +except ModuleNotFoundError as e: + helper.auto_installer_fix(e) + +logger = logging.getLogger(__name__) + +MIGRATE_TABLE = 'migratehistory' +MIGRATE_TEMPLATE = '''# Generated by database migrator +import peewee + +def migrate(migrator, db): + """ + Write your migrations here. + """ +{migrate} + +def rollback(migrator, db): + """ + Write your rollback migrations here. + """ +{rollback}''' + + +class MigrateHistory(peewee.Model): + """ + Presents the migration history in a database. + """ + + name = peewee.CharField(unique=True) + migrated_at = peewee.DateTimeField(default=datetime.utcnow) + + # noinspection PyTypeChecker + def __unicode__(self) -> str: + """ + String representation of this migration + """ + return self.name + + class Meta: + table_name = MIGRATE_TABLE + + +def get_model(method): + """ + Convert string to model class. + """ + + @wraps(method) + def wrapper(migrator, model, *args, **kwargs): + if isinstance(model, str): + return method(migrator, migrator.table_dict[model], *args, **kwargs) + return method(migrator, model, *args, **kwargs) + return wrapper + + +# noinspection PyProtectedMember +class Migrator(object): + def __init__(self, database: t.Union[peewee.Database, peewee.Proxy]): + """ + Initializes the migrator + """ + if isinstance(database, peewee.Proxy): + database = database.obj + self.database: SqliteDatabase = database + self.table_dict: t.Dict[str, peewee.Model] = {} + self.operations: t.List[t.Union[Operation, callable]] = [] + self.migrator = SqliteMigrator(database) + + def run(self): + """ + Runs operations. + """ + for op in self.operations: + if isinstance(op, Operation): + op.run() + else: + op() + self.clean() + + def clean(self): + """ + Cleans the operations. + """ + self.operations = list() + + def sql(self, sql: str, *params): + """ + Executes raw SQL. + """ + self.operations.append(SQL(sql, *params)) + + def create_table(self, model: peewee.Model) -> peewee.Model: + """ + Creates model and table in database. + """ + self.table_dict[model._meta.table_name] = model + model._meta.database = self.database + self.operations.append(model.create_table) + return model + + @get_model + def drop_table(self, model: peewee.Model): + """ + Drops model and table from database. + """ + del self.table_dict[model._meta.table_name] + self.operations.append(lambda: model.drop_table(cascade=False)) + + @get_model + def add_columns(self, model: peewee.Model, **fields: peewee.Field) -> peewee.Model: + """ + Creates new fields. + """ + for name, field in fields.items(): + model._meta.add_field(name, field) + self.operations.append(self.migrator.add_column( + model._meta.table_name, field.column_name, field)) + if field.unique: + self.operations.append(self.migrator.add_index( + model._meta.table_name, (field.column_name,), unique=True)) + return model + + @get_model + def drop_columns(self, model: peewee.Model, names: str) -> peewee.Model: + """ + Removes fields from model. + """ + fields = [field for field in model._meta.fields.values() + if field.name in names] + for field in fields: + self.__del_field__(model, field) + if field.unique: + # Drop unique index + index_name = make_index_name( + model._meta.table_name, [field.column_name]) + self.operations.append(self.migrator.drop_index( + model._meta.table_name, index_name)) + self.operations.append( + self.migrator.drop_column( + model._meta.table_name, field.column_name, cascade=False)) + return model + + def __del_field__(self, model: peewee.Model, field: peewee.Field): + """ + Deletes field from model. + """ + model._meta.remove_field(field.name) + delattr(model, field.name) + if isinstance(field, peewee.ForeignKeyField): + obj_id_name = field.column_name + if field.column_name == field.name: + obj_id_name += '_id' + delattr(model, obj_id_name) + delattr(field.rel_model, field.backref) + + @get_model + def rename_column(self, model: peewee.Model, old_name: str, new_name: str) -> peewee.Model: + """ + Renames field in model. + """ + field = model._meta.fields[old_name] + if isinstance(field, peewee.ForeignKeyField): + old_name = field.column_name + self.__del_field__(model, field) + field.name = field.column_name = new_name + model._meta.add_field(new_name, field) + if isinstance(field, peewee.ForeignKeyField): + field.column_name = new_name = field.column_name + '_id' + self.operations.append(self.migrator.rename_column( + model._meta.table_name, old_name, new_name)) + return model + + @get_model + def rename_table(self, model: peewee.Model, new_name: str) -> peewee.Model: + """ + Renames table in database. + """ + old_name = model._meta.table_name + del self.table_dict[model._meta.table_name] + model._meta.table_name = new_name + self.table_dict[model._meta.table_name] = model + self.operations.append(self.migrator.rename_table(old_name, new_name)) + return model + + @get_model + def add_index(self, model: peewee.Model, *columns: str, unique=False) -> peewee.Model: + """Create indexes.""" + model._meta.indexes.append((columns, unique)) + columns_ = [] + for col in columns: + field = model._meta.fields.get(col) + + if len(columns) == 1: + field.unique = unique + field.index = not unique + + if isinstance(field, peewee.ForeignKeyField): + col = col + '_id' + + columns_.append(col) + self.operations.append(self.migrator.add_index( + model._meta.table_name, columns_, unique=unique)) + return model + + @get_model + def drop_index(self, model: peewee.Model, *columns: str) -> peewee.Model: + """Drop indexes.""" + columns_ = [] + for col in columns: + field = model._meta.fields.get(col) + if not field: + continue + + if len(columns) == 1: + field.unique = field.index = False + + if isinstance(field, peewee.ForeignKeyField): + col = col + '_id' + columns_.append(col) + index_name = make_index_name(model._meta.table_name, columns_) + model._meta.indexes = [(cols, _) for ( + cols, _) in model._meta.indexes if columns != cols] + self.operations.append(self.migrator.drop_index( + model._meta.table_name, index_name)) + return model + + @get_model + def add_not_null(self, model: peewee.Model, *names: str) -> peewee.Model: + """Add not null.""" + for name in names: + field = model._meta.fields[name] + field.null = False + self.operations.append(self.migrator.add_not_null( + model._meta.table_name, field.column_name)) + return model + + @get_model + def drop_not_null(self, model: peewee.Model, *names: str) -> peewee.Model: + """Drop not null.""" + for name in names: + field = model._meta.fields[name] + field.null = True + self.operations.append(self.migrator.drop_not_null( + model._meta.table_name, field.column_name)) + return model + + @get_model + def add_default(self, model: peewee.Model, name: str, default: t.Any) -> peewee.Model: + """Add default.""" + field = model._meta.fields[name] + model._meta.defaults[field] = field.default = default + self.operations.append(self.migrator.apply_default( + model._meta.table_name, name, field)) + return model + + +# noinspection PyProtectedMember +class MigrationManager(object): + filemask = re.compile(r"[\d]+_[^\.]+\.py$") + + def __init__(self, database: t.Union[peewee.Database, peewee.Proxy]): + """ + Initializes the migration manager. + """ + if not isinstance(database, (peewee.Database, peewee.Proxy)): + raise RuntimeError('Invalid database: {}'.format(database)) + self.database = database + + @cached_property + def model(self) -> t.Type[MigrateHistory]: + """ + Initialize and cache the MigrationHistory model. + """ + MigrateHistory._meta.database = self.database + MigrateHistory._meta.table_name = 'migratehistory' + MigrateHistory._meta.schema = None + MigrateHistory.create_table(True) + return MigrateHistory + + @property + def done(self) -> t.List[str]: + """ + Scans migrations in the database. + """ + return [mm.name for mm in self.model.select().order_by(self.model.id)] + + @property + def todo(self): + """ + Scans migrations in the file system. + """ + if not os.path.exists(helper.migration_dir): + logger.warning('Migration directory: {} does not exist.'.format( + helper.migration_dir)) + os.makedirs(helper.migration_dir) + return sorted(f[:-3] for f in os.listdir(helper.migration_dir) if self.filemask.match(f)) + + @property + def diff(self) -> t.List[str]: + """ + Calculates difference between the filesystem and the database. + """ + done = set(self.done) + return [name for name in self.todo if name not in done] + + @cached_property + def migrator(self) -> Migrator: + """ + Create migrator and setup it with fake migrations. + """ + migrator = Migrator(self.database) + for name in self.done: + self.up_one(name, migrator, True) + return migrator + + def compile(self, name, migrate='', rollback=''): + """ + Compiles a migration. + """ + name = datetime.utcnow().strftime('%Y%m%d%H%M%S') + '_' + name + filename = name + '.py' + path = os.path.join(helper.migration_dir, filename) + with open(path, 'w') as f: + f.write(MIGRATE_TEMPLATE.format( + migrate=migrate, rollback=rollback, name=filename)) + + return name + + def create(self, name: str = 'auto', auto: bool = False) -> t.Optional[str]: + """ + Creates a migration. + """ + migrate = rollback = '' + if auto: + raise NotImplementedError + + logger.info('Creating migration "{}"'.format(name)) + name = self.compile(name, migrate, rollback) + logger.info('Migration has been created as "{}"'.format(name)) + return name + + def clear(self): + """Clear migrations.""" + self.model.delete().execute() + + def up(self, name: t.Optional[str] = None): + """ + Runs all unapplied migrations. + """ + logger.info('Starting migrations') + console.info('Starting migrations') + + done = [] + diff = self.diff + if not diff: + logger.info('There is nothing to migrate') + console.info('There is nothing to migrate') + return done + + migrator = self.migrator + for mname in diff: + done.append(self.up_one(mname, self.migrator)) + if name and name == mname: + break + + return done + + def read(self, name: str): + """ + Reads a migration from a file. + """ + call_params = dict() + if helper.is_os_windows() and sys.version_info >= (3, 0): + # if system is windows - force utf-8 encoding + call_params['encoding'] = 'utf-8' + with open(os.path.join(helper.migration_dir, name + '.py'), **call_params) as f: + code = f.read() + scope = {} + code = compile(code, '', 'exec', dont_inherit=True) + exec(code, scope, None) + return scope.get('migrate', lambda m, d: None), scope.get('rollback', lambda m, d: None) + + def up_one(self, name: str, migrator: Migrator, + fake: bool = False, rollback: bool = False) -> str: + """ + Runs a migration with a given name. + """ + try: + migrate_fn, rollback_fn = self.read(name) + if fake: + migrate_fn(migrator, self.database) + migrator.clean() + return name + with self.database.transaction(): + if rollback: + logger.info('Rolling back "{}"'.format(name)) + rollback_fn(migrator, self.database) + migrator.run() + self.model.delete().where(self.model.name == name).execute() + else: + logger.info('Migrate "{}"'.format(name)) + migrate_fn(migrator, self.database) + migrator.run() + if name not in self.done: + self.model.create(name=name) + + logger.info('Done "{}"'.format(name)) + return name + + except Exception: + self.database.rollback() + operation_name = 'Rollback' if rollback else 'Migration' + logger.exception('{} failed: {}'.format(operation_name, name)) + raise + + def down(self): + """ + Rolls back migrations. + """ + if not self.done: + raise RuntimeError('No migrations are found.') + + name = self.done[-1] + + migrator = self.migrator + self.up_one(name, migrator, False, True) + logger.warning('Rolled back migration: {}'.format(name)) diff --git a/app/classes/shared/permission_helper.py b/app/classes/shared/permission_helper.py new file mode 100644 index 00000000..cd1ee52a --- /dev/null +++ b/app/classes/shared/permission_helper.py @@ -0,0 +1,22 @@ +from enum import Enum + +class PermissionHelper: + @staticmethod + def both_have_perm(a: str, b: str, permission_tested: Enum): + return permission_helper.combine_perm_bool(a[permission_tested.value], b[permission_tested.value]) + + @staticmethod + def combine_perm(a: str, b: str) -> str: + return '1' if (a == '1' and b == '1') else '0' + + @staticmethod + def combine_perm_bool(a: str, b: str) -> bool: + return a == '1' and b == '1' + + @staticmethod + def combine_masks(permission_mask_a: str, permission_mask_b: str) -> str: + both_masks = zip(list(permission_mask_a), list(permission_mask_b)) + return ''.join(map(lambda x: permission_helper.combine_perm(x[0], x[1]), both_masks)) + + +permission_helper = PermissionHelper() diff --git a/app/classes/shared/server.py b/app/classes/shared/server.py index 1b4ad0c0..7ebafdb3 100644 --- a/app/classes/shared/server.py +++ b/app/classes/shared/server.py @@ -1,39 +1,36 @@ import os -import sys import re -import json import time import datetime +import base64 import threading import logging.config -import zipfile -from threading import Thread -import shutil import subprocess -import zlib import html +import tempfile - +from app.classes.minecraft.stats import Stats +from app.classes.minecraft.mc_ping import ping, ping_bedrock +from app.classes.models.servers import Server_Stats, servers_helper +from app.classes.models.management import management_helper +from app.classes.models.users import users_helper +from app.classes.models.server_permissions import server_permissions from app.classes.shared.helpers import helper from app.classes.shared.console import console -from app.classes.models.servers import Servers, servers_helper -from app.classes.models.management import management_helper -from app.classes.web.websocket_helper import websocket_helper from app.classes.shared.translation import translation - -logger = logging.getLogger(__name__) - +from app.classes.shared.file_helpers import file_helper +from app.classes.web.websocket_helper import websocket_helper try: import psutil - #import pexpect - import schedule + #TZLocal is set as a hidden import on win pipeline + from tzlocal import get_localzone + from apscheduler.schedulers.background import BackgroundScheduler except ModuleNotFoundError as e: - logger.critical("Import Error: Unable to load {} module".format(e.name), exc_info=True) - console.critical("Import Error: Unable to load {} module".format(e.name)) - sys.exit(1) + helper.auto_installer_fix(e) +logger = logging.getLogger(__name__) class ServerOutBuf: lines = {} @@ -67,17 +64,17 @@ class ServerOutBuf: def check(self): while True: if self.proc.poll() is None: - char = self.proc.stdout.read(1).decode('utf-8') + char = self.proc.stdout.read(1).decode('utf-8', 'ignore') # TODO: we may want to benchmark reading in blocks and userspace processing it later, reads are kind of expensive as a syscall self.process_byte(char) else: - flush = self.proc.stdout.read().decode('utf-8') + flush = self.proc.stdout.read().decode('utf-8', 'ignore') for char in flush: self.process_byte(char) break def new_line_handler(self, new_line): - new_line = re.sub('(\033\\[(0;)?[0-9]*[A-z]?(;[0-9])?m?)|(> )', '', new_line) + new_line = re.sub('(\033\\[(0;)?[0-9]*[A-z]?(;[0-9])?m?)', ' ', new_line) new_line = re.sub('[A-z]{2}\b\b', '', new_line) highlighted = helper.log_colors(html.escape(new_line)) @@ -96,6 +93,9 @@ class ServerOutBuf: ) +#************************************************************************************************ +# Minecraft Server Class +#************************************************************************************************ class Server: def __init__(self, stats): @@ -113,73 +113,105 @@ class Server: self.name = None self.is_crashed = False self.restart_count = 0 - self.crash_watcher_schedule = None self.stats = stats + tz = get_localzone() + self.server_scheduler = BackgroundScheduler(timezone=str(tz)) + self.server_scheduler.start() self.backup_thread = threading.Thread(target=self.a_backup_server, daemon=True, name=f"backup_{self.name}") self.is_backingup = False + #Reset crash and update at initialization + servers_helper.server_crash_reset(self.server_id) + servers_helper.set_update(self.server_id, False) + +#************************************************************************************************ +# Minecraft Server Management +#************************************************************************************************ def reload_server_settings(self): server_data = servers_helper.get_server_data_by_id(self.server_id) self.settings = server_data def do_server_setup(self, server_data_obj): - logger.info('Creating Server object: {} | Server Name: {} | Auto Start: {}'.format( - server_data_obj['server_id'], - server_data_obj['server_name'], - server_data_obj['auto_start'] - )) - self.server_id = server_data_obj['server_id'] - self.name = server_data_obj['server_name'] + serverId = server_data_obj['server_id'] + serverName = server_data_obj['server_name'] + autoStart = server_data_obj['auto_start'] + + logger.info(f'Creating Server object: {serverId} | Server Name: {serverName} | Auto Start: {autoStart}') + self.server_id = serverId + self.name = serverName self.settings = server_data_obj + self.record_server_stats() + # build our server run command if server_data_obj['auto_start']: delay = int(self.settings['auto_start_delay']) - logger.info("Scheduling server {} to start in {} seconds".format(self.name, delay)) - console.info("Scheduling server {} to start in {} seconds".format(self.name, delay)) + logger.info(f"Scheduling server {self.name} to start in {delay} seconds") + console.info(f"Scheduling server {self.name} to start in {delay} seconds") - schedule.every(delay).seconds.do(self.run_scheduled_server) + self.server_scheduler.add_job(self.run_scheduled_server, 'interval', seconds=delay, id=str(self.server_id)) def run_scheduled_server(self): - console.info("Starting server ID: {} - {}".format(self.server_id, self.name)) - logger.info("Starting server {}".format(self.server_id, self.name)) - self.run_threaded_server() + console.info(f"Starting server ID: {self.server_id} - {self.name}") + logger.info(f"Starting server ID: {self.server_id} - {self.name}") + #Sets waiting start to false since we're attempting to start the server. + servers_helper.set_waiting_start(self.server_id, False) + self.run_threaded_server(None) # remove the scheduled job since it's ran - return schedule.CancelJob + return self.server_scheduler.remove_job(str(self.server_id)) - def run_threaded_server(self, lang): + def run_threaded_server(self, user_id): # start the server - self.server_thread = threading.Thread(target=self.start_server, daemon=True, args=(lang,), name='{}_server_thread'.format(self.server_id)) + self.server_thread = threading.Thread(target=self.start_server, daemon=True, args=(user_id,), name=f'{self.server_id}_server_thread') self.server_thread.start() + #Register an shedule for polling server stats when running + logger.info(f"Polling server statistics {self.name} every {5} seconds") + console.info(f"Polling server statistics {self.name} every {5} seconds") + try: + self.server_scheduler.add_job(self.realtime_stats, 'interval', seconds=5, id="stats_"+str(self.server_id)) + except: + self.server_scheduler.remove_job('stats_'+str(self.server_id)) + self.server_scheduler.add_job(self.realtime_stats, 'interval', seconds=5, id="stats_"+str(self.server_id)) + + def setup_server_run_command(self): # configure the server - server_exec_path = self.settings['executable'] + server_exec_path = helper.get_os_understandable_path(self.settings['executable']) self.server_command = helper.cmdparse(self.settings['execution_command']) - self.server_path = self.settings['path'] + self.server_path = helper.get_os_understandable_path(self.settings['path']) # let's do some quick checking to make sure things actually exists full_path = os.path.join(self.server_path, server_exec_path) if not helper.check_file_exists(full_path): - logger.critical("Server executable path: {} does not seem to exist".format(full_path)) - console.critical("Server executable path: {} does not seem to exist".format(full_path)) + logger.critical(f"Server executable path: {full_path} does not seem to exist") + console.critical(f"Server executable path: {full_path} does not seem to exist") if not helper.check_path_exists(self.server_path): - logger.critical("Server path: {} does not seem to exits".format(self.server_path)) - console.critical("Server path: {} does not seem to exits".format(self.server_path)) - helper.do_exit() + logger.critical(f"Server path: {self.server_path} does not seem to exits") + console.critical(f"Server path: {self.server_path} does not seem to exits") if not helper.check_writeable(self.server_path): - logger.critical("Unable to write/access {}".format(self.server_path)) - console.warning("Unable to write/access {}".format(self.server_path)) - helper.do_exit() + logger.critical(f"Unable to write/access {self.server_path}") + console.warning(f"Unable to write/access {self.server_path}") - def start_server(self, user_lang): + def start_server(self, user_id): + if not user_id: + user_lang = helper.get_setting('language') + else: + user_lang = users_helper.get_user_lang_by_id(user_id) - logger.info("Start command detected. Reloading settings from DB for server {}".format(self.name)) + if servers_helper.get_download_status(self.server_id): + if user_id: + websocket_helper.broadcast_user(user_id, 'send_start_error',{ + 'error': translation.translate('error', 'not-downloaded', user_lang) + }) + return False + + logger.info(f"Start command detected. Reloading settings from DB for server {self.name}") self.setup_server_run_command() # fail safe in case we try to start something already running if self.check_running(): @@ -190,65 +222,164 @@ class Server: logger.error("Server is updating. Terminating startup.") return False - logger.info("Launching Server {} with command {}".format(self.name, self.server_command)) - console.info("Launching Server {} with command {}".format(self.name, self.server_command)) + logger.info(f"Launching Server {self.name} with command {self.server_command}") + console.info(f"Launching Server {self.name} with command {self.server_command}") - if os.name == "nt": + #Checks for eula. Creates one if none detected. + #If EULA is detected and not set to one of these true vaiants we offer to set it true. + if helper.check_file_exists(os.path.join(self.settings['path'], 'eula.txt')): + f = open(os.path.join(self.settings['path'], 'eula.txt'), 'r', encoding='utf-8') + line = f.readline().lower() + if line == 'eula=true': + e_flag = True + + elif line == 'eula = true': + e_flag = True + + elif line == 'eula= true': + e_flag = True + + elif line == 'eula =true': + e_flag = True + + else: + e_flag = False + else: + e_flag = False + + if not e_flag: + if user_id: + websocket_helper.broadcast_user(user_id, 'send_eula_bootbox', { + 'id': self.server_id + }) + else: + logger.error("Autostart failed due to EULA being false. Agree not sent due to auto start.") + return False + return False + f.close() + if helper.is_os_windows(): logger.info("Windows Detected") - creationflags=subprocess.CREATE_NEW_CONSOLE else: logger.info("Unix Detected") - creationflags=None - logger.info("Starting server in {p} with command: {c}".format(p=self.server_path, c=self.server_command)) - - servers_helper.set_waiting_start(self.server_id, False) + logger.info(f"Starting server in {self.server_path} with command: {self.server_command}") + + #checks to make sure file is openable (downloaded) and exists. try: - self.process = subprocess.Popen(self.server_command, cwd=self.server_path, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - except Exception as ex: - msg = "Server {} failed to start with error code: {}".format(self.name, ex) - logger.error(msg) - websocket_helper.broadcast('send_start_error', { - 'error': translation.translate('error', 'start-error', user_lang).format(self.name, ex) - }) - return False - if helper.check_internet(): - loc_server_port = servers_helper.get_server_stats_by_id(self.server_id)['server_port'] - if helper.check_port(loc_server_port): - websocket_helper.broadcast('send_start_reload', { + f = open(os.path.join(self.server_path, servers_helper.get_server_data_by_id(self.server_id)['executable']), "r", encoding="utf-8") + f.close() + + except: + if user_id: + websocket_helper.broadcast_user(user_id, 'send_start_error',{ + 'error': translation.translate('error', 'not-downloaded', user_lang) }) - else: - websocket_helper.broadcast('send_start_error', { - 'error': translation.translate('error', 'closedPort', user_lang).format(loc_server_port) - }) + return + + if not helper.is_os_windows() and servers_helper.get_server_type_by_id(self.server_id) == "minecraft-bedrock": + logger.info(f"Bedrock and Unix detected for server {self.name}. Switching to appropriate execution string") + my_env = os.environ + my_env["LD_LIBRARY_PATH"] = self.server_path + try: + self.process = subprocess.Popen( + self.server_command, cwd=self.server_path, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=my_env) + except Exception as ex: + logger.error(f"Server {self.name} failed to start with error code: {ex}") + if user_id: + websocket_helper.broadcast_user(user_id, 'send_start_error',{ + 'error': translation.translate('error', 'start-error', user_lang).format(self.name, ex) + }) + return False + else: - websocket_helper.broadcast('send_start_error', { - 'error': translation.translate('error', 'internet', user_lang) - }) - servers_helper.set_waiting_start(self.server_id, False) + try: + self.process = subprocess.Popen( + self.server_command, cwd=self.server_path, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + except Exception as ex: + #Checks for java on initial fail + if os.system("java -version") == 32512: + if user_id: + websocket_helper.broadcast_user(user_id, 'send_start_error',{ + 'error': translation.translate('error', 'noJava', user_lang).format(self.name) + }) + return False + else: + logger.error(f"Server {self.name} failed to start with error code: {ex}") + if user_id: + websocket_helper.broadcast_user(user_id, 'send_start_error',{ + 'error': translation.translate('error', 'start-error', user_lang).format(self.name, ex) + }) + return False + out_buf = ServerOutBuf(self.process, self.server_id) - logger.debug('Starting virtual terminal listener for server {}'.format(self.name)) - threading.Thread(target=out_buf.check, daemon=True, name='{}_virtual_terminal'.format(self.server_id)).start() + logger.debug(f'Starting virtual terminal listener for server {self.name}') + threading.Thread(target=out_buf.check, daemon=True, name=f'{self.server_id}_virtual_terminal').start() self.is_crashed = False + servers_helper.server_crash_reset(self.server_id) self.start_time = str(datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')) if self.process.poll() is None: - logger.info("Server {} running with PID {}".format(self.name, self.process.pid)) - console.info("Server {} running with PID {}".format(self.name, self.process.pid)) + logger.info(f"Server {self.name} running with PID {self.process.pid}") + console.info(f"Server {self.name} running with PID {self.process.pid}") self.is_crashed = False - self.stats.record_stats() + servers_helper.server_crash_reset(self.server_id) + self.record_server_stats() + check_internet_thread = threading.Thread( + target=self.check_internet_thread, daemon=True, args=(user_id, user_lang, ), name=f"{self.name}_Internet") + check_internet_thread.start() + #Checks if this is the servers first run. + if servers_helper.get_first_run(self.server_id): + servers_helper.set_first_run(self.server_id) + loc_server_port = servers_helper.get_server_stats_by_id(self.server_id)['server_port'] + #Sends port reminder message. + websocket_helper.broadcast_user(user_id, 'send_start_error', { + 'error': translation.translate('error', 'portReminder', user_lang).format(self.name, loc_server_port) + }) + server_users = server_permissions.get_server_user_list(self.server_id) + for user in server_users: + if user != user_id: + websocket_helper.broadcast_user(user, 'send_start_reload', { + }) + else: + server_users = server_permissions.get_server_user_list(self.server_id) + for user in server_users: + websocket_helper.broadcast_user(user, 'send_start_reload', { + }) else: - logger.warning("Server PID {} died right after starting - is this a server config issue?".format(self.process.pid)) - console.warning("Server PID {} died right after starting - is this a server config issue?".format(self.process.pid)) + logger.warning(f"Server PID {self.process.pid} died right after starting - is this a server config issue?") + console.warning(f"Server PID {self.process.pid} died right after starting - is this a server config issue?") if self.settings['crash_detection']: - logger.info("Server {} has crash detection enabled - starting watcher task".format(self.name)) - console.info("Server {} has crash detection enabled - starting watcher task".format(self.name)) + logger.info(f"Server {self.name} has crash detection enabled - starting watcher task") + console.info(f"Server {self.name} has crash detection enabled - starting watcher task") - self.crash_watcher_schedule = schedule.every(30).seconds.do(self.detect_crash).tag(self.name) + self.server_scheduler.add_job(self.detect_crash, 'interval', seconds=30, id=f"c_{self.server_id}") + + def check_internet_thread(self, user_id, user_lang): + if user_id: + if not helper.check_internet(): + websocket_helper.broadcast_user(user_id, 'send_start_error', { + 'error': translation.translate('error', 'internet', user_lang) + }) + + def stop_crash_detection(self): + #This is only used if the crash detection settings change while the server is running. + if self.check_running(): + logger.info(f"Detected crash detection shut off for server {self.name}") + try: + self.server_scheduler.remove_job('c_' + str(self.server_id)) + except: + logger.error(f"Removing crash watcher for server {self.name} failed. Assuming it was never started.") + + def start_crash_detection(self): + #This is only used if the crash detection settings change while the server is running. + if self.check_running(): + logger.info(f"Server {self.name} has crash detection enabled - starting watcher task") + console.info(f"Server {self.name} has crash detection enabled - starting watcher task") + self.server_scheduler.add_job(self.detect_crash, 'interval', seconds=30, id=f"c_{self.server_id}") def stop_threaded_server(self): self.stop_server() @@ -259,13 +390,20 @@ class Server: def stop_server(self): if self.settings['stop_command']: self.send_command(self.settings['stop_command']) + if self.settings['crash_detection']: + #remove crash detection watcher + logger.info(f"Removing crash watcher for server {self.name}") + try: + self.server_scheduler.remove_job('c_' + str(self.server_id)) + except: + logger.error(f"Removing crash watcher for server {self.name} failed. Assuming it was never started.") else: #windows will need to be handled separately for Ctrl+C self.process.terminate() running = self.check_running() if not running: - logger.info("Can't stop server {} if it's not running".format(self.name)) - console.info("Can't stop server {} if it's not running".format(self.name)) + logger.info(f"Can't stop server {self.name} if it's not running") + console.info(f"Can't stop server {self.name} if it's not running") return x = 0 @@ -273,9 +411,10 @@ class Server: server_name = self.name server_pid = self.process.pid + while running: x = x+1 - logstr = "Server {} is still running - waiting 2s to see if it stops ({} seconds until force close)".format(server_name, int(60-(x*2))) + logstr = f"Server {server_name} is still running - waiting 2s to see if it stops ({int(60-(x*2))} seconds until force close)" logger.info(logstr) console.info(logstr) running = self.check_running() @@ -283,27 +422,34 @@ class Server: # if we haven't closed in 60 seconds, let's just slam down on the PID if x >= 30: - logger.info("Server {} is still running - Forcing the process down".format(server_name)) - console.info("Server {} is still running - Forcing the process down".format(server_name)) + logger.info(f"Server {server_name} is still running - Forcing the process down") + console.info(f"Server {server_name} is still running - Forcing the process down") self.kill() - logger.info("Stopped Server {} with PID {}".format(server_name, server_pid)) - console.info("Stopped Server {} with PID {}".format(server_name, server_pid)) + logger.info(f"Stopped Server {server_name} with PID {server_pid}") + console.info(f"Stopped Server {server_name} with PID {server_pid}") # massive resetting of variables self.cleanup_server_object() + server_users = server_permissions.get_server_user_list(self.server_id) - self.stats.record_stats() + # remove the stats polling job since server is stopped + self.server_scheduler.remove_job("stats_"+str(self.server_id)) - def restart_threaded_server(self, lang): + self.record_server_stats() + for user in server_users: + websocket_helper.broadcast_user(user, 'send_start_reload', { + }) + + def restart_threaded_server(self, user_id): # if not already running, let's just start if not self.check_running(): - self.run_threaded_server(lang) + self.run_threaded_server(user_id) else: self.stop_threaded_server() time.sleep(2) - self.run_threaded_server(lang) + self.run_threaded_server(user_id) def cleanup_server_object(self): self.start_time = None @@ -324,48 +470,48 @@ class Server: return False def send_command(self, command): - console.info("COMMAND TIME: {}".format(command)) if not self.check_running() and command.lower() != 'start': - logger.warning("Server not running, unable to send command \"{}\"".format(command)) + logger.warning(f"Server not running, unable to send command \"{command}\"") return False - - logger.debug("Sending command {} to server".format(command)) + console.info(f"COMMAND TIME: {command}") + logger.debug(f"Sending command {command} to server") # send it - self.process.stdin.write("{}\n".format(command).encode('utf-8')) + self.process.stdin.write(f"{command}\n".encode('utf-8')) self.process.stdin.flush() def crash_detected(self, name): # clear the old scheduled watcher task - self.remove_watcher_thread() + self.server_scheduler.remove_job(f"c_{self.server_id}") + # remove the stats polling job since server is stopped + self.server_scheduler.remove_job("stats_"+str(self.server_id)) # the server crashed, or isn't found - so let's reset things. - logger.warning("The server {} seems to have vanished unexpectedly, did it crash?".format(name)) + logger.warning(f"The server {name} seems to have vanished unexpectedly, did it crash?") if self.settings['crash_detection']: - logger.warning("The server {} has crashed and will be restarted. Restarting server".format(name)) - console.warning("The server {} has crashed and will be restarted. Restarting server".format(name)) - self.run_threaded_server() + logger.warning(f"The server {name} has crashed and will be restarted. Restarting server") + console.warning(f"The server {name} has crashed and will be restarted. Restarting server") + self.run_threaded_server(None) return True else: - logger.critical( - "The server {} has crashed, crash detection is disabled and it will not be restarted".format(name)) - console.critical( - "The server {} has crashed, crash detection is disabled and it will not be restarted".format(name)) + logger.critical(f"The server {name} has crashed, crash detection is disabled and it will not be restarted") + console.critical(f"The server {name} has crashed, crash detection is disabled and it will not be restarted") return False def kill(self): - logger.info("Terminating server {} and all child processes".format(self.server_id)) + logger.info(f"Terminating server {self.server_id} and all child processes") process = psutil.Process(self.process.pid) # for every sub process... for proc in process.children(recursive=True): # kill all the child processes - it sounds too wrong saying kill all the children (kevdagoat: lol!) - logger.info("Sending SIGKILL to server {}".format(proc.name)) + logger.info(f"Sending SIGKILL to server {proc.name}") proc.kill() # kill the main process we are after logger.info('Sending SIGKILL to parent') + self.server_scheduler.remove_job("stats_"+str(self.server_id)) self.process.kill() def get_start_time(self): @@ -379,17 +525,25 @@ class Server: return self.process.pid else: return None - + def detect_crash(self): - logger.info("Detecting possible crash for server: {} ".format(self.name)) + logger.info(f"Detecting possible crash for server: {self.name} ") running = self.check_running() # if all is okay, we just exit out if running: return + #check the exit code -- This could be a fix for /stop + if self.process.returncode == 0: + logger.warning(f'Process {self.process.pid} exited with code {self.process.returncode}. This is considered a clean exit'+ + ' supressing crash handling.') + # cancel the watcher task + self.server_scheduler.remove_job("c_"+str(self.server_id)) + return + servers_helper.sever_crashed(self.server_id) # if we haven't tried to restart more 3 or more times if self.restart_count <= 3: @@ -402,23 +556,27 @@ class Server: # we have tried to restart 4 times... elif self.restart_count == 4: - logger.critical("Server {} has been restarted {} times. It has crashed, not restarting.".format( - self.name, self.restart_count)) + logger.critical(f"Server {self.name} has been restarted {self.restart_count} times. It has crashed, not restarting.") + console.critical(f"Server {self.name} has been restarted {self.restart_count} times. It has crashed, not restarting.") - console.critical("Server {} has been restarted {} times. It has crashed, not restarting.".format( - self.name, self.restart_count)) - - # set to 99 restart attempts so this elif is skipped next time. (no double logging) - self.restart_count = 99 + self.restart_count = 0 self.is_crashed = True + servers_helper.sever_crashed(self.server_id) # cancel the watcher task - self.remove_watcher_thread() + self.server_scheduler.remove_job("c_"+str(self.server_id)) def remove_watcher_thread(self): logger.info("Removing old crash detection watcher thread") console.info("Removing old crash detection watcher thread") - schedule.clear(self.name) + self.server_scheduler.remove_job('c_'+str(self.server_id)) + + def agree_eula(self, user_id): + file = os.path.join(self.server_path, 'eula.txt') + f = open(file, 'w', encoding='utf-8') + f.write('eula=true') + f.close() + self.run_threaded_server(user_id) def is_backup_running(self): if self.is_backingup: @@ -428,51 +586,96 @@ class Server: def backup_server(self): backup_thread = threading.Thread(target=self.a_backup_server, daemon=True, name=f"backup_{self.name}") - logger.info("Starting Backup Thread for server {}.".format(self.settings['server_name'])) - if self.server_path == None: - self.server_path = self.settings['path'] + logger.info(f"Starting Backup Thread for server {self.settings['server_name']}.") + if self.server_path is None: + self.server_path = helper.get_os_understandable_path(self.settings['path']) logger.info("Backup Thread - Local server path not defined. Setting local server path variable.") #checks if the backup thread is currently alive for this server if not self.is_backingup: try: backup_thread.start() except Exception as ex: - logger.error("Failed to start backup: {}".format(ex)) + logger.error(f"Failed to start backup: {ex}") return False else: - logger.error("Backup is already being processed for server {}. Canceling backup request".format(self.settings['server_name'])) + logger.error(f"Backup is already being processed for server {self.settings['server_name']}. Canceling backup request") return False - logger.info("Backup Thread started for server {}.".format(self.settings['server_name'])) + logger.info(f"Backup Thread started for server {self.settings['server_name']}.") def a_backup_server(self): - logger.info("Starting server {} (ID {}) backup".format(self.name, self.server_id)) + logger.info(f"Starting server {self.name} (ID {self.server_id}) backup") + server_users = server_permissions.get_server_user_list(self.server_id) + for user in server_users: + websocket_helper.broadcast_user(user, 'notification', translation.translate('notify', + 'backupStarted', users_helper.get_user_lang_by_id(user)).format(self.name)) + time.sleep(3) self.is_backingup = True conf = management_helper.get_backup_config(self.server_id) try: - backup_filename = "{}/{}".format(self.settings['backup_path'], datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')) - logger.info("Creating backup of server '{}' (ID#{}) at '{}'".format(self.settings['server_name'], self.server_id, backup_filename)) - shutil.make_archive(backup_filename, 'zip', self.server_path) + backup_filename = f"{self.settings['backup_path']}/{datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}" + logger.info(f"Creating backup of server '{self.settings['server_name']}'" + + f" (ID#{self.server_id}, path={self.server_path}) at '{backup_filename}'") + + tempDir = tempfile.mkdtemp() + # pylint: disable=unexpected-keyword-arg + file_helper.copy_dir(self.server_path, tempDir, dirs_exist_ok=True) + excluded_dirs = management_helper.get_excluded_backup_dirs(self.server_id) + server_dir = helper.get_os_understandable_path(self.settings['path']) + + for my_dir in excluded_dirs: + # Take the full path of the excluded dir and replace the server path with the temp path + # This is so that we're only deleting excluded dirs from the temp path and not the server path + excluded_dir = helper.get_os_understandable_path(my_dir).replace(server_dir, helper.get_os_understandable_path(tempDir)) + # Next, check to see if it is a directory + if os.path.isdir(excluded_dir): + # If it is a directory, recursively delete the entire directory from the backup + file_helper.del_dirs(excluded_dir) + else: + # If not, just remove the file + os.remove(excluded_dir) + if conf['compress']: + logger.debug("Found compress backup to be true. Calling compressed archive") + file_helper.make_compressed_archive(helper.get_os_understandable_path(backup_filename), tempDir) + else: + logger.debug("Found compress backup to be false. Calling NON-compressed archive") + file_helper.make_archive(helper.get_os_understandable_path(backup_filename), tempDir) + while len(self.list_backups()) > conf["max_backups"] and conf["max_backups"] > 0: backup_list = self.list_backups() oldfile = backup_list[0] - oldfile_path = "{}/{}".format(conf['backup_path'], oldfile['path']) - logger.info("Removing old backup '{}'".format(oldfile['path'])) - os.remove(oldfile_path) + oldfile_path = f"{conf['backup_path']}/{oldfile['path']}" + logger.info(f"Removing old backup '{oldfile['path']}'") + os.remove(helper.get_os_understandable_path(oldfile_path)) + self.is_backingup = False - logger.info("Backup of server: {} completed".format(self.name)) + file_helper.del_dirs(tempDir) + logger.info(f"Backup of server: {self.name} completed") + server_users = server_permissions.get_server_user_list(self.server_id) + for user in server_users: + websocket_helper.broadcast_user(user, 'notification', translation.translate('notify', 'backupComplete', + users_helper.get_user_lang_by_id(user)).format(self.name)) + time.sleep(3) return except: - logger.exception("Failed to create backup of server {} (ID {})".format(self.name, self.server_id)) + logger.exception(f"Failed to create backup of server {self.name} (ID {self.server_id})") self.is_backingup = False return def list_backups(self): - conf = management_helper.get_backup_config(self.server_id) - if helper.check_path_exists(self.settings['backup_path']): - files = helper.get_human_readable_files_sizes(helper.list_dir_by_date(self.settings['backup_path'])) - return [{"path": os.path.relpath(f['path'], start=conf['backup_path']), "size": f["size"]} for f in files] + if self.settings['backup_path']: + if helper.check_path_exists(helper.get_os_understandable_path(self.settings['backup_path'])): + files = ( + helper.get_human_readable_files_sizes(helper.list_dir_by_date(helper.get_os_understandable_path(self.settings['backup_path'])))) + return [{ + "path": os.path.relpath(f['path'], + start=helper.get_os_understandable_path(self.settings['backup_path'])), + "size": f["size"] + } for f in files] + else: + return [] else: - return [] + logger.info(f"Error putting backup file list for server with ID: {self.server_id}") + return[] def jar_update(self): servers_helper.set_update(self.server_id, True) @@ -480,20 +683,19 @@ class Server: update_thread.start() def check_update(self): - server_stats = servers_helper.get_server_stats_by_id(self.server_id) - if server_stats['updating']: + + if servers_helper.get_server_stats_by_id(self.server_id)['updating']: return True else: return False def a_jar_update(self): - error = False wasStarted = "-1" self.backup_server() #checks if server is running. Calls shutdown if it is running. if self.check_running(): wasStarted = True - logger.info("Server with PID {} is running. Sending shutdown command".format(self.process.pid)) + logger.info(f"Server with PID {self.process.pid} is running. Sending shutdown command") self.stop_threaded_server() else: wasStarted = False @@ -501,30 +703,32 @@ class Server: # There are clients self.check_update() message = ' UPDATING...' - websocket_helper.broadcast('update_button_status', { + websocket_helper.broadcast_page('/panel/server_detail', 'update_button_status', { 'isUpdating': self.check_update(), 'server_id': self.server_id, 'wasRunning': wasStarted, 'string': message }) - backup_dir = os.path.join(self.settings['path'], 'crafty_executable_backups') + websocket_helper.broadcast_page('/panel/dashboard', 'send_start_reload', { + }) + backup_dir = os.path.join(helper.get_os_understandable_path(self.settings['path']), 'crafty_executable_backups') #checks if backup directory already exists if os.path.isdir(backup_dir): backup_executable = os.path.join(backup_dir, 'old_server.jar') else: - logger.info("Executable backup directory not found for Server: {}. Creating one.".format(self.name)) + logger.info(f"Executable backup directory not found for Server: {self.name}. Creating one.") os.mkdir(backup_dir) backup_executable = os.path.join(backup_dir, 'old_server.jar') if os.path.isfile(backup_executable): #removes old backup - logger.info("Old backup found for server: {}. Removing...".format(self.name)) + logger.info(f"Old backup found for server: {self.name}. Removing...") os.remove(backup_executable) - logger.info("Old backup removed for server: {}.".format(self.name)) + logger.info(f"Old backup removed for server: {self.name}.") else: - logger.info("No old backups found for server: {}".format(self.name)) + logger.info(f"No old backups found for server: {self.name}") - current_executable = os.path.join(self.settings['path'], self.settings['executable']) + current_executable = os.path.join(helper.get_os_understandable_path(self.settings['path']), self.settings['executable']) #copies to backup dir helper.copy_files(current_executable, backup_executable) @@ -534,29 +738,395 @@ class Server: while servers_helper.get_server_stats_by_id(self.server_id)['updating']: if downloaded and not self.is_backingup: - print("Backup Status: " + str(self.is_backingup)) logger.info("Executable updated successfully. Starting Server") servers_helper.set_update(self.server_id, False) if len(websocket_helper.clients) > 0: # There are clients self.check_update() - websocket_helper.broadcast('notification', "Executable update finished for " + self.name) + server_users = server_permissions.get_server_user_list(self.server_id) + for user in server_users: + websocket_helper.broadcast_user(user, 'notification', "Executable update finished for " + self.name) time.sleep(3) - websocket_helper.broadcast('update_button_status', { + websocket_helper.broadcast_page('/panel/server_detail', 'update_button_status', { 'isUpdating': self.check_update(), 'server_id': self.server_id, 'wasRunning': wasStarted }) - websocket_helper.broadcast('notification', "Executable update finished for "+self.name) + websocket_helper.broadcast_page('/panel/dashboard', 'send_start_reload', { + }) + server_users = server_permissions.get_server_user_list(self.server_id) + for user in server_users: + websocket_helper.broadcast_user(user, 'notification', "Executable update finished for "+self.name) - management_helper.add_to_audit_log_raw('Alert', '-1', self.server_id, "Executable update finished for "+self.name, self.settings['server_ip']) + management_helper.add_to_audit_log_raw( + 'Alert', '-1', self.server_id, "Executable update finished for "+self.name, self.settings['server_ip']) if wasStarted: self.start_server() elif not downloaded and not self.is_backingup: time.sleep(5) - servers_helper.set_update(self.server_id, False) - websocket_helper.broadcast('notification', + server_users = server_permissions.get_server_user_list(self.server_id) + for user in server_users: + websocket_helper.broadcast_user(user,'notification', "Executable update failed for " + self.name + ". Check log file for details.") logger.error("Executable download failed.") - pass + + + +#************************************************************************************************ +# Minecraft Servers Statistics +#************************************************************************************************ + + def realtime_stats(self): + total_players = 0 + max_players = 0 + servers_ping = [] + raw_ping_result = [] + raw_ping_result = self.get_raw_server_stats(self.server_id) + + if f"{raw_ping_result.get('icon')}" == "b''": + raw_ping_result['icon'] = False + + servers_ping.append({ + 'id': raw_ping_result.get('id'), + 'started': raw_ping_result.get('started'), + 'running': raw_ping_result.get('running'), + 'cpu': raw_ping_result.get('cpu'), + 'mem': raw_ping_result.get('mem'), + 'mem_percent': raw_ping_result.get('mem_percent'), + 'world_name': raw_ping_result.get('world_name'), + 'world_size': raw_ping_result.get('world_size'), + 'server_port': raw_ping_result.get('server_port'), + 'int_ping_results': raw_ping_result.get('int_ping_results'), + 'online': raw_ping_result.get('online'), + 'max': raw_ping_result.get('max'), + 'players': raw_ping_result.get('players'), + 'desc': raw_ping_result.get('desc'), + 'version': raw_ping_result.get('version'), + 'icon': raw_ping_result.get('icon') + }) + if len(websocket_helper.clients) > 0: + websocket_helper.broadcast_page_params( + '/panel/server_detail', + { + 'id': str(self.server_id) + }, + 'update_server_details', + { + 'id': raw_ping_result.get('id'), + 'started': raw_ping_result.get('started'), + 'running': raw_ping_result.get('running'), + 'cpu': raw_ping_result.get('cpu'), + 'mem': raw_ping_result.get('mem'), + 'mem_percent': raw_ping_result.get('mem_percent'), + 'world_name': raw_ping_result.get('world_name'), + 'world_size': raw_ping_result.get('world_size'), + 'server_port': raw_ping_result.get('server_port'), + 'int_ping_results': raw_ping_result.get('int_ping_results'), + 'online': raw_ping_result.get('online'), + 'max': raw_ping_result.get('max'), + 'players': raw_ping_result.get('players'), + 'desc': raw_ping_result.get('desc'), + 'version': raw_ping_result.get('version'), + 'icon': raw_ping_result.get('icon') + } + ) + total_players += int(raw_ping_result.get('online')) + max_players += int(raw_ping_result.get('max')) + + self.record_server_stats() + + if (len(servers_ping) > 0) & (len(websocket_helper.clients) > 0): + try: + websocket_helper.broadcast_page('/panel/dashboard', 'update_server_status', servers_ping) + websocket_helper.broadcast_page('/status', 'update_server_status', servers_ping) + except: + console.warning("Can't broadcast server status to websocket") + + def get_servers_stats(self): + + server_stats = {} + + logger.info("Getting Stats for Server " + self.name + " ...") + + server_id = self.server_id + server = servers_helper.get_server_data_by_id(server_id) + + logger.debug(f'Getting stats for server: {server_id}') + + # get our server object, settings and data dictionaries + self.reload_server_settings() + + # world data + server_path = server['path'] + + # process stats + p_stats = Stats._get_process_stats(self.process) + + # TODO: search server properties file for possible override of 127.0.0.1 + internal_ip = server['server_ip'] + server_port = server['server_port'] + server_name = server.get('server_name', f"ID#{server_id}") + + logger.debug("Pinging server '{server}' on {internal_ip}:{server_port}") + if servers_helper.get_server_type_by_id(server_id) == 'minecraft-bedrock': + int_mc_ping = ping_bedrock(internal_ip, int(server_port)) + else: + int_mc_ping = ping(internal_ip, int(server_port)) + + int_data = False + ping_data = {} + + # if we got a good ping return, let's parse it + if int_mc_ping: + int_data = True + if servers_helper.get_server_type_by_id(server['server_id']) == 'minecraft-bedrock': + ping_data = Stats.parse_server_RakNet_ping(int_mc_ping) + else: + ping_data = Stats.parse_server_ping(int_mc_ping) + #Makes sure we only show stats when a server is online otherwise people have gotten confused. + if self.check_running(): + server_stats = { + 'id': server_id, + 'started': self.get_start_time(), + 'running': self.check_running(), + 'cpu': p_stats.get('cpu_usage', 0), + 'mem': p_stats.get('memory_usage', 0), + "mem_percent": p_stats.get('mem_percentage', 0), + 'world_name': server_name, + 'world_size': Stats.get_world_size(server_path), + 'server_port': server_port, + 'int_ping_results': int_data, + 'online': ping_data.get("online", False), + "max": ping_data.get("max", False), + 'players': ping_data.get("players", False), + 'desc': ping_data.get("server_description", False), + 'version': ping_data.get("server_version", False) + } + else: + server_stats = { + 'id': server_id, + 'started': self.get_start_time(), + 'running': self.check_running(), + 'cpu': p_stats.get('cpu_usage', 0), + 'mem': p_stats.get('memory_usage', 0), + "mem_percent": p_stats.get('mem_percentage', 0), + 'world_name': server_name, + 'world_size': Stats.get_world_size(server_path), + 'server_port': server_port, + 'int_ping_results': int_data, + 'online': False, + "max": False, + 'players': False, + 'desc': False, + 'version': False + } + + return server_stats + + def get_server_players(self): + + server = servers_helper.get_server_data_by_id(self.server_id) + + logger.info(f"Getting players for server {server}") + + # get our settings and data dictionaries + # server_settings = server.get('server_settings', {}) + # server_data = server.get('server_data_obj', {}) + + + # TODO: search server properties file for possible override of 127.0.0.1 + internal_ip = server['server_ip'] + server_port = server['server_port'] + + logger.debug("Pinging {internal_ip} on port {server_port}") + if servers_helper.get_server_type_by_id(self.server_id) != 'minecraft-bedrock': + int_mc_ping = ping(internal_ip, int(server_port)) + + + ping_data = {} + + # if we got a good ping return, let's parse it + if int_mc_ping: + ping_data = Stats.parse_server_ping(int_mc_ping) + return ping_data['players'] + return [] + + def get_raw_server_stats(self, server_id): + + try: + server = servers_helper.get_server_obj(server_id) + except: + return {'id': server_id, + 'started': False, + 'running': False, + 'cpu': 0, + 'mem': 0, + "mem_percent": 0, + 'world_name': None, + 'world_size': None, + 'server_port': None, + 'int_ping_results': False, + 'online': False, + 'max': False, + 'players': False, + 'desc': False, + 'version': False, + 'icon': False} + + server_stats = {} + server = servers_helper.get_server_obj(server_id) + if not server: + return {} + server_dt = servers_helper.get_server_data_by_id(server_id) + + + logger.debug(f'Getting stats for server: {server_id}') + + # get our server object, settings and data dictionaries + self.reload_server_settings() + + # world data + server_name = server_dt['server_name'] + server_path = server_dt['path'] + + # process stats + p_stats = Stats._get_process_stats(self.process) + + # TODO: search server properties file for possible override of 127.0.0.1 + #internal_ip = server['server_ip'] + #server_port = server['server_port'] + internal_ip = server_dt['server_ip'] + server_port = server_dt['server_port'] + + + logger.debug(f"Pinging server '{self.name}' on {internal_ip}:{server_port}") + if servers_helper.get_server_type_by_id(server_id) == 'minecraft-bedrock': + int_mc_ping = ping_bedrock(internal_ip, int(server_port)) + else: + int_mc_ping = ping(internal_ip, int(server_port)) + + int_data = False + ping_data = {} + #Makes sure we only show stats when a server is online otherwise people have gotten confused. + if self.check_running(): + # if we got a good ping return, let's parse it + if servers_helper.get_server_type_by_id(server_id) != 'minecraft-bedrock': + if int_mc_ping: + int_data = True + ping_data = Stats.parse_server_ping(int_mc_ping) + + server_stats = { + 'id': server_id, + 'started': self.get_start_time(), + 'running': self.check_running(), + 'cpu': p_stats.get('cpu_usage', 0), + 'mem': p_stats.get('memory_usage', 0), + "mem_percent": p_stats.get('mem_percentage', 0), + 'world_name': server_name, + 'world_size': Stats.get_world_size(server_path), + 'server_port': server_port, + 'int_ping_results': int_data, + 'online': ping_data.get("online", False), + "max": ping_data.get("max", False), + 'players': ping_data.get("players", False), + 'desc': ping_data.get("server_description", False), + 'version': ping_data.get("server_version", False), + 'icon': ping_data.get("server_icon", False) + } + + else: + if int_mc_ping: + int_data = True + ping_data = Stats.parse_server_RakNet_ping(int_mc_ping) + try: + server_icon = base64.encodebytes(ping_data['icon']) + except Exception as ex: + server_icon = False + logger.info(f"Unable to read the server icon : {ex}") + + server_stats = { + 'id': server_id, + 'started': self.get_start_time(), + 'running': self.check_running(), + 'cpu': p_stats.get('cpu_usage', 0), + 'mem': p_stats.get('memory_usage', 0), + "mem_percent": p_stats.get('mem_percentage', 0), + 'world_name': server_name, + 'world_size': Stats.get_world_size(server_path), + 'server_port': server_port, + 'int_ping_results': int_data, + 'online': ping_data['online'], + 'max': ping_data['max'], + 'players': [], + 'desc': ping_data['server_description'], + 'version': ping_data['server_version'], + 'icon': server_icon + } + else: + server_stats = { + 'id': server_id, + 'started': self.get_start_time(), + 'running': self.check_running(), + 'cpu': p_stats.get('cpu_usage', 0), + 'mem': p_stats.get('memory_usage', 0), + "mem_percent": p_stats.get('mem_percentage', 0), + 'world_name': server_name, + 'world_size': Stats.get_world_size(server_path), + 'server_port': server_port, + 'int_ping_results': int_data, + 'online': False, + 'max': False, + 'players': False, + 'desc': False, + 'version': False, + 'icon': False + } + else: + server_stats = { + 'id': server_id, + 'started': self.get_start_time(), + 'running': self.check_running(), + 'cpu': p_stats.get('cpu_usage', 0), + 'mem': p_stats.get('memory_usage', 0), + "mem_percent": p_stats.get('mem_percentage', 0), + 'world_name': server_name, + 'world_size': Stats.get_world_size(server_path), + 'server_port': server_port, + 'int_ping_results': int_data, + 'online': False, + "max": False, + 'players': False, + 'desc': False, + 'version': False + } + + return server_stats + + def record_server_stats(self): + + server = self.get_servers_stats() + Server_Stats.insert({ + Server_Stats.server_id: server.get('id', 0), + Server_Stats.started: server.get('started', ""), + Server_Stats.running: server.get('running', False), + Server_Stats.cpu: server.get('cpu', 0), + Server_Stats.mem: server.get('mem', 0), + Server_Stats.mem_percent: server.get('mem_percent', 0), + Server_Stats.world_name: server.get('world_name', ""), + Server_Stats.world_size: server.get('world_size', ""), + Server_Stats.server_port: server.get('server_port', ""), + Server_Stats.int_ping_results: server.get('int_ping_results', False), + Server_Stats.online: server.get("online", False), + Server_Stats.max: server.get("max", False), + Server_Stats.players: server.get("players", False), + Server_Stats.desc: server.get("desc", False), + Server_Stats.version: server.get("version", False) + }).execute() + + # delete old data + max_age = helper.get_setting("history_max_age") + now = datetime.datetime.now() + last_week = now.day - max_age + + Server_Stats.delete().where(Server_Stats.created < last_week).execute() diff --git a/app/classes/shared/tasks.py b/app/classes/shared/tasks.py index 800335b9..bad0c79f 100644 --- a/app/classes/shared/tasks.py +++ b/app/classes/shared/tasks.py @@ -1,31 +1,29 @@ import os -import sys -import json import time import logging import threading import asyncio -import shutil +import datetime +from app.classes.controllers.users_controller import Users_Controller +from app.classes.minecraft.serverjars import server_jar_obj +from app.classes.models.management import management_helper +from app.classes.models.users import users_helper from app.classes.shared.helpers import helper from app.classes.shared.console import console -from app.classes.web.tornado import Webserver +from app.classes.web.tornado_handler import Webserver from app.classes.web.websocket_helper import websocket_helper -from app.classes.minecraft.serverjars import server_jar_obj -from app.classes.models.servers import servers_helper -from app.classes.models.management import management_helper - -logger = logging.getLogger(__name__) - try: - import schedule + from tzlocal import get_localzone + from apscheduler.events import EVENT_JOB_EXECUTED + from apscheduler.schedulers.background import BackgroundScheduler + from apscheduler.triggers.cron import CronTrigger -except ModuleNotFoundError as e: - logger.critical("Import Error: Unable to load {} module".format(e.name), exc_info=True) - console.critical("Import Error: Unable to load {} module".format(e.name)) - sys.exit(1) +except ModuleNotFoundError as err: + helper.auto_installer_fix(err) +logger = logging.getLogger('apscheduler') scheduler_intervals = { 'seconds', 'minutes', 'hours', @@ -46,9 +44,13 @@ class TasksManager: self.controller = controller self.tornado = Webserver(controller, self) + self.tz = get_localzone() + self.scheduler = BackgroundScheduler(timezone=str(self.tz)) + + self.users_controller = Users_Controller() + self.webserver_thread = threading.Thread(target=self.tornado.run_tornado, daemon=True, name='tornado_thread') - self.main_kill_switch_thread = threading.Thread(target=self.main_kill_switch, daemon=True, name="main_loop") self.main_thread_exiting = False self.schedule_thread = threading.Thread(target=self.scheduler_thread, daemon=True, name="scheduler") @@ -65,65 +67,49 @@ class TasksManager: def get_main_thread_run_status(self): return self.main_thread_exiting - def start_main_kill_switch_watcher(self): - self.main_kill_switch_thread.start() - - def main_kill_switch(self): - while True: - if os.path.exists(os.path.join(helper.root_dir, 'exit.txt')): - logger.info("Found Exit File, stopping everything") - self._main_graceful_exit() - time.sleep(5) - def reload_schedule_from_db(self): jobs = management_helper.get_schedules_enabled() - schedule.clear(tag='backup') - schedule.clear(tag='db') - for j in jobs: - if j.interval_type in scheduler_intervals: - logger.info("Loading schedule ID#{i}: '{a}' every {n} {t} at {s}".format( - i=j.schedule_id, a=j.action, n=j.interval, t=j.interval_type, s=j.start_time)) - try: - getattr(schedule.every(j.interval), j.interval_type).at(j.start_time).do( - self.controller.management.send_command, 0, j.server_id, "127.27.23.89", j.action) - except schedule.ScheduleValueError as e: - logger.critical("Scheduler value error occurred: {} on ID#{}".format(e, j.schedule_id)) - else: - logger.critical("Unknown schedule job type '{}' at id {}, skipping".format(j.interval_type, j.schedule_id)) - + logger.info("Reload from DB called. Current enabled schedules: ") + for item in jobs: + logger.info(f"JOB: {item}") + def command_watcher(self): while True: # select any commands waiting to be processed commands = management_helper.get_unactioned_commands() for c in commands: + try: + svr = self.controller.get_server_obj(c.server_id) + except: + logger.error("Server value requested does note exist purging item from waiting commands.") + management_helper.mark_command_complete(c.command_id) - svr = self.controller.get_server_obj(c['server_id']['server_id']) - user_lang = c.get('user')['lang'] - command = c.get('command', None) + user_id = c.user_id + command = c.command if command == 'start_server': - svr.run_threaded_server(user_lang) + svr.run_threaded_server(user_id) elif command == 'stop_server': svr.stop_threaded_server() elif command == "restart_server": - svr.restart_threaded_server(user_lang) + svr.restart_threaded_server(user_id) elif command == "backup_server": svr.backup_server() elif command == "update_executable": svr.jar_update() - management_helper.mark_command_complete(c.get('command_id', None)) + else: + svr.send_command(command) + management_helper.mark_command_complete(c.command_id) time.sleep(1) def _main_graceful_exit(self): try: os.remove(helper.session_file) - os.remove(os.path.join(helper.root_dir, 'exit.txt')) - os.remove(os.path.join(helper.root_dir, '.header')) self.controller.stop_all_servers() except: logger.info("Caught error during shutdown", exc_info=True) @@ -159,33 +145,283 @@ class TasksManager: console.info("Launching realtime thread...") self.realtime_thread.start() - @staticmethod - def scheduler_thread(): - while True: - schedule.run_pending() - time.sleep(1) + def scheduler_thread(self): + schedules = management_helper.get_schedules_enabled() + self.scheduler.add_listener(self.schedule_watcher, mask=EVENT_JOB_EXECUTED) + #self.scheduler.add_job(self.scheduler.print_jobs, 'interval', seconds=10, id='-1') + + #load schedules from DB + for schedule in schedules: + if schedule.interval != 'reaction': + if schedule.cron_string != "": + try: + self.scheduler.add_job(management_helper.add_command, + CronTrigger.from_crontab(schedule.cron_string, + timezone=str(self.tz)), + id = str(schedule.schedule_id), + args = [schedule.server_id, + self.users_controller.get_id_by_name('system'), + '127.0.0.1', + schedule.command] + ) + except Exception as e: + console.error(f"Failed to schedule task with error: {e}.") + console.warning("Removing failed task from DB.") + logger.error(f"Failed to schedule task with error: {e}.") + logger.warning("Removing failed task from DB.") + #remove items from DB if task fails to add to apscheduler + management_helper.delete_scheduled_task(schedule.schedule_id) + else: + if schedule.interval_type == 'hours': + self.scheduler.add_job(management_helper.add_command, + 'cron', + minute = 0, + hour = '*/'+str(schedule.interval), + id = str(schedule.schedule_id), + args = [schedule.server_id, + self.users_controller.get_id_by_name('system'), + '127.0.0.1', + schedule.command] + ) + elif schedule.interval_type == 'minutes': + self.scheduler.add_job(management_helper.add_command, + 'cron', + minute = '*/'+str(schedule.interval), + id = str(schedule.schedule_id), + args = [schedule.server_id, + self.users_controller.get_id_by_name('system'), + '127.0.0.1', + schedule.command] + ) + elif schedule.interval_type == 'days': + curr_time = schedule.start_time.split(':') + self.scheduler.add_job(management_helper.add_command, + 'cron', + day = '*/'+str(schedule.interval), + hour=curr_time[0], + minute=curr_time[1], + id=str(schedule.schedule_id), + args=[schedule.server_id, + self.users_controller.get_id_by_name('system'), + '127.0.0.1', + schedule.command] + ) + self.scheduler.start() + jobs = self.scheduler.get_jobs() + logger.info("Loaded schedules. Current enabled schedules: ") + for item in jobs: + logger.info(f"JOB: {item}") + + def schedule_job(self, job_data): + sch_id = management_helper.create_scheduled_task( + job_data['server_id'], + job_data['action'], + job_data['interval'], + job_data['interval_type'], + job_data['start_time'], + job_data['command'], + "None", + job_data['enabled'], + job_data['one_time'], + job_data['cron_string'], + job_data['parent'], + job_data['delay']) + #Checks to make sure some doofus didn't actually make the newly created task a child of itself. + if str(job_data['parent']) == str(sch_id): + management_helper.update_scheduled_task(sch_id, {'parent':None}) + #Check to see if it's enabled and is not a chain reaction. + if job_data['enabled'] and job_data['interval_type'] != 'reaction': + if job_data['cron_string'] != "": + try: + self.scheduler.add_job(management_helper.add_command, + CronTrigger.from_crontab(job_data['cron_string'], + timezone=str(self.tz)), + id=str(sch_id), + args=[job_data['server_id'], + self.users_controller.get_id_by_name('system'), + '127.0.0.1', + job_data['command']] + ) + except Exception as e: + console.error(f"Failed to schedule task with error: {e}.") + console.warning("Removing failed task from DB.") + logger.error(f"Failed to schedule task with error: {e}.") + logger.warning("Removing failed task from DB.") + #remove items from DB if task fails to add to apscheduler + management_helper.delete_scheduled_task(sch_id) + else: + if job_data['interval_type'] == 'hours': + self.scheduler.add_job(management_helper.add_command, + 'cron', + minute = 0, + hour = '*/'+str(job_data['interval']), + id=str(sch_id), + args=[job_data['server_id'], + self.users_controller.get_id_by_name('system'), + '127.0.0.1', + job_data['command']] + ) + elif job_data['interval_type'] == 'minutes': + self.scheduler.add_job(management_helper.add_command, + 'cron', + minute = '*/'+str(job_data['interval']), + id=str(sch_id), + args=[job_data['server_id'], + self.users_controller.get_id_by_name('system'), + '127.0.0.1', + job_data['command']] + ) + elif job_data['interval_type'] == 'days': + curr_time = job_data['start_time'].split(':') + self.scheduler.add_job(management_helper.add_command, + 'cron', + day = '*/'+str(job_data['interval']), + hour = curr_time[0], + minute = curr_time[1], + id=str(sch_id), + args=[job_data['server_id'], + self.users_controller.get_id_by_name('system'), + '127.0.0.1', + job_data['command']], + ) + logger.info("Added job. Current enabled schedules: ") + jobs = self.scheduler.get_jobs() + for item in jobs: + logger.info(f"JOB: {item}") + + def remove_all_server_tasks(self, server_id): + schedules = management_helper.get_schedules_by_server(server_id) + for schedule in schedules: + if schedule.interval != 'reaction': + self.remove_job(schedule.schedule_id) + + def remove_job(self, sch_id): + job = management_helper.get_scheduled_task_model(sch_id) + for schedule in management_helper.get_child_schedules(sch_id): + management_helper.update_scheduled_task(schedule.schedule_id, {'parent':None}) + management_helper.delete_scheduled_task(sch_id) + if job.enabled and job.interval_type != 'reaction': + self.scheduler.remove_job(str(sch_id)) + logger.info(f"Job with ID {sch_id} was deleted.") + else: + logger.info(f"Job with ID {sch_id} was deleted from DB, but was not enabled." + + "Not going to try removing something that doesn't exist from active schedules.") + + def update_job(self, sch_id, job_data): + management_helper.update_scheduled_task(sch_id, job_data) + #Checks to make sure some doofus didn't actually make the newly created task a child of itself. + if str(job_data['parent']) == str(sch_id): + management_helper.update_scheduled_task(sch_id, {'parent':None}) + try: + if job_data['interval'] != 'reaction': + self.scheduler.remove_job(str(sch_id)) + except: + logger.info("No job found in update job. Assuming it was previously disabled. Starting new job.") + + if job_data['enabled']: + if job_data['interval'] != 'reaction': + if job_data['cron_string'] != "": + try: + self.scheduler.add_job(management_helper.add_command, + CronTrigger.from_crontab(job_data['cron_string'], + timezone=str(self.tz)), + id=str(sch_id), + args=[job_data['server_id'], + self.users_controller.get_id_by_name('system'), + '127.0.0.1', + job_data['command']] + ) + except Exception as e: + console.error(f"Failed to schedule task with error: {e}.") + console.info("Removing failed task from DB.") + management_helper.delete_scheduled_task(sch_id) + else: + if job_data['interval_type'] == 'hours': + self.scheduler.add_job(management_helper.add_command, + 'cron', + minute = 0, + hour = '*/'+str(job_data['interval']), + id=str(sch_id), + args=[job_data['server_id'], + self.users_controller.get_id_by_name('system'), + '127.0.0.1', + job_data['command']] + ) + elif job_data['interval_type'] == 'minutes': + self.scheduler.add_job(management_helper.add_command, + 'cron', + minute = '*/'+str(job_data['interval']), + id=str(sch_id), + args=[job_data['server_id'], + self.users_controller.get_id_by_name('system'), + '127.0.0.1', + job_data['command']] + ) + elif job_data['interval_type'] == 'days': + curr_time = job_data['start_time'].split(':') + self.scheduler.add_job(management_helper.add_command, + 'cron', + day = '*/'+str(job_data['interval']), + hour = curr_time[0], + minute = curr_time[1], + id=str(sch_id), + args=[job_data['server_id'], + self.users_controller.get_id_by_name('system'), + '127.0.0.1', + job_data['command']] + ) + else: + try: + self.scheduler.get_job(str(sch_id)) + self.scheduler.remove_job(str(sch_id)) + except: + logger.info(f"APScheduler found no scheduled job on schedule update for schedule with id: {sch_id} Assuming it was already disabled.") + + def schedule_watcher(self, event): + if not event.exception: + if str(event.job_id).isnumeric(): + task = management_helper.get_scheduled_task_model(int(event.job_id)) + management_helper.add_to_audit_log_raw('system', users_helper.get_user_id_by_name('system'), task.server_id, + f"Task with id {task.schedule_id} completed successfully", '127.0.0.1') + #check if the task is a single run. + if task.one_time: + self.remove_job(task.schedule_id) + logger.info("one time task detected. Deleting...") +#check for any child tasks for this. It's kind of backward, but this makes DB management a lot easier. One to one instead of one to many. + for schedule in management_helper.get_child_schedules_by_server(task.schedule_id, task.server_id): + #event job ID's are strings so we need to look at this as the same data type. + if str(schedule.parent) == str(event.job_id): + if schedule.enabled: + delaytime = datetime.datetime.now() + datetime.timedelta(seconds=schedule.delay) + self.scheduler.add_job(management_helper.add_command, 'date', run_date=delaytime, id=str(schedule.schedule_id), + args=[schedule.server_id, + self.users_controller.get_id_by_name('system'), + '127.0.0.1', + schedule.command]) + else: + logger.info("Event job ID is not numerical. Assuming it's stats - not stored in DB. Moving on.") + else: + logger.error(f"Task failed with error: {event.exception}") def start_stats_recording(self): stats_update_frequency = helper.get_setting('stats_update_frequency') - logger.info("Stats collection frequency set to {stats} seconds".format(stats=stats_update_frequency)) - console.info("Stats collection frequency set to {stats} seconds".format(stats=stats_update_frequency)) + logger.info(f"Stats collection frequency set to {stats_update_frequency} seconds") + console.info(f"Stats collection frequency set to {stats_update_frequency} seconds") # one for now, self.controller.stats.record_stats() - # one for later - schedule.every(stats_update_frequency).seconds.do(self.controller.stats.record_stats).tag('stats-recording') + self.scheduler.add_job(self.controller.stats.record_stats, 'interval', seconds=stats_update_frequency, id="stats") - @staticmethod - def serverjar_cache_refresher(): + + def serverjar_cache_refresher(self): logger.info("Refreshing serverjars.com cache on start") server_jar_obj.refresh_cache() logger.info("Scheduling Serverjars.com cache refresh service every 12 hours") - schedule.every(12).hours.do(server_jar_obj.refresh_cache).tag('serverjars') + self.scheduler.add_job(server_jar_obj.refresh_cache, 'interval', hours=12, id="serverjars") - @staticmethod - def realtime(): + def realtime(self): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) @@ -210,9 +446,7 @@ class TasksManager: 'mem_percent': host_stats.get('mem_percent'), 'mem_usage': host_stats.get('mem_usage') }) - time.sleep(4) def log_watcher(self): self.controller.servers.check_for_old_logs() - schedule.every(6).hours.do(lambda: self.controller.servers.check_for_old_logs()).tag('log-mgmt') - + self.scheduler.add_job(self.controller.servers.check_for_old_logs, 'interval', hours=6, id="log-mgmt") diff --git a/app/classes/shared/translation.py b/app/classes/shared/translation.py index 6d0a0a1b..63730647 100644 --- a/app/classes/shared/translation.py +++ b/app/classes/shared/translation.py @@ -1,71 +1,75 @@ -from app.classes.shared.helpers import helper -from app.classes.shared.console import console - -import os import json - import logging +import os +import typing as t + +from app.classes.shared.console import console +from app.classes.shared.helpers import helper logger = logging.getLogger(__name__) -class Translation(): +class Translation: def __init__(self): self.translations_path = os.path.join(helper.root_dir, 'app', 'translations') self.cached_translation = None self.cached_translation_lang = None - self.lang_file_exists = [] - def translate(self, page, word, lang): - translated_word = None - fallback_lang = 'en_EN' - if lang not in self.lang_file_exists and \ - helper.check_file_exists(os.path.join(self.translations_path, str(lang) + '.json')): - self.lang_file_exists.append(lang) + def get_language_file(self, language: str): + return os.path.join(self.translations_path, str(language) + '.json') - translated_word = self.translate_inner(page, word, lang) \ - if lang in self.lang_file_exists else self.translate_inner(page, word, fallback_lang) + def translate(self, page, word, language): + fallback_language = 'en_EN' + + translated_word = self.translate_inner(page, word, language) + if translated_word is None: + translated_word = self.translate_inner(page, word, fallback_language) if translated_word: - if isinstance(translated_word, dict): return json.dumps(translated_word) - elif iter(translated_word) and not isinstance(translated_word, str): return '\n'.join(translated_word) - return translated_word + if isinstance(translated_word, dict): + # JSON objects + return json.dumps(translated_word) + elif isinstance(translated_word, str): + # Basic strings + return translated_word + elif hasattr(translated_word, '__iter__'): + # Multiline strings + return '\n'.join(translated_word) return 'Error while getting translation' - def translate_inner(self, page, word, lang): - lang_file = os.path.join( - self.translations_path, - lang + '.json' - ) + + def translate_inner(self, page, word, language) -> t.Union[t.Any, None]: + language_file = self.get_language_file(language) try: if not self.cached_translation: - with open(lang_file, 'r', encoding='utf-8') as f: + with open(language_file, 'r', encoding='utf-8') as f: data = json.load(f) self.cached_translation = data - elif self.cached_translation_lang != lang: - with open(lang_file, 'r', encoding='utf-8') as f: + elif self.cached_translation_lang != language: + with open(language_file, 'r', encoding='utf-8') as f: data = json.load(f) self.cached_translation = data - self.cached_translation_lang = lang + self.cached_translation_lang = language else: data = self.cached_translation try: translated_page = data[page] except KeyError: - logger.error('Translation File Error: page {} does not exist for lang {}'.format(page, lang)) - console.error('Translation File Error: page {} does not exist for lang {}'.format(page, lang)) + logger.error(f'Translation File Error: page {page} does not exist for lang {language}') + console.error(f'Translation File Error: page {page} does not exist for lang {language}') return None try: translated_word = translated_page[word] return translated_word except KeyError: - logger.error('Translation File Error: word {} does not exist on page {} for lang {}'.format(word, page, lang)) - console.error('Translation File Error: word {} does not exist on page {} for lang {}'.format(word, page, lang)) + logger.error(f'Translation File Error: word {word} does not exist on page {page} for lang {language}') + console.error(f'Translation File Error: word {word} does not exist on page {page} for lang {language}') return None except Exception as e: - logger.critical('Translation File Error: Unable to read {} due to {}'.format(lang_file, e)) - console.critical('Translation File Error: Unable to read {} due to {}'.format(lang_file, e)) + logger.critical(f'Translation File Error: Unable to read {language_file} due to {e}') + console.critical(f'Translation File Error: Unable to read {language_file} due to {e}') return None -translation = Translation() \ No newline at end of file + +translation = Translation() diff --git a/app/classes/web/ajax_handler.py b/app/classes/web/ajax_handler.py index 78ad91d0..16d6b130 100644 --- a/app/classes/web/ajax_handler.py +++ b/app/classes/web/ajax_handler.py @@ -1,27 +1,27 @@ -import json -import logging -import tempfile -import threading -from typing import Container -import zipfile - -import tornado.web -import tornado.escape -import bleach import os -import shutil import html import re +import logging +import time +from app.classes.models.server_permissions import Enum_Permissions_Server from app.classes.shared.console import console -from app.classes.shared.main_models import Users, installer -from app.classes.web.base_handler import BaseHandler from app.classes.shared.helpers import helper +from app.classes.shared.translation import translation from app.classes.shared.server import ServerOutBuf +from app.classes.web.websocket_helper import websocket_helper +from app.classes.web.base_handler import BaseHandler + +try: + import bleach + import tornado.web + import tornado.escape + +except ModuleNotFoundError as ex: + helper.auto_installer_fix(ex) logger = logging.getLogger(__name__) - class AjaxHandler(BaseHandler): def render_page(self, template, page_data): @@ -33,13 +33,13 @@ class AjaxHandler(BaseHandler): @tornado.web.authenticated def get(self, page): - user_data = json.loads(self.get_secure_cookie("user_data")) + _, _, exec_user = self.current_user error = bleach.clean(self.get_argument('error', "WTF Error!")) template = "panel/denied.html" page_data = { - 'user_data': user_data, + 'user_data': exec_user, 'error': error } @@ -65,11 +65,11 @@ class AjaxHandler(BaseHandler): return if not server_data['log_path']: - logger.warning("Log path not found in server_log ajax call ({})".format(server_id)) + logger.warning(f"Log path not found in server_log ajax call ({server_id})") if full_log: log_lines = helper.get_setting('max_log_lines') - data = helper.tail_file(server_data['log_path'], log_lines) + data = helper.tail_file(helper.get_os_understandable_path(server_data['log_path']), log_lines) else: data = ServerOutBuf.lines.get(server_id, []) @@ -79,70 +79,206 @@ class AjaxHandler(BaseHandler): d = re.sub('(\033\\[(0;)?[0-9]*[A-z]?(;[0-9])?m?)|(> )', '', d) d = re.sub('[A-z]{2}\b\b', '', d) line = helper.log_colors(html.escape(d)) - self.write('{}
        '.format(line)) + self.write(f'{line}
        ') # self.write(d.encode("utf-8")) except Exception as e: - logger.warning("Skipping Log Line due to error: {}".format(e)) - pass + logger.warning(f"Skipping Log Line due to error: {e}") elif page == "announcements": data = helper.get_announcements() page_data['notify_data'] = data self.render_page('ajax/notify.html', page_data) - elif page == "get_file": - file_path = self.get_argument('file_path', None) - server_id = self.get_argument('id', None) - if not self.check_server_id(server_id, 'get_file'): return - else: server_id = bleach.clean(server_id) + elif page == "get_zip_tree": + path = self.get_argument('path', None) - if not helper.in_path(self.controller.servers.get_server_data_by_id(server_id)['path'], file_path)\ - or not helper.check_file_exists(os.path.abspath(file_path)): - logger.warning("Invalid path in get_file ajax call ({})".format(file_path)) - console.warning("Invalid path in get_file ajax call ({})".format(file_path)) - return - - - error = None - - try: - with open(file_path) as file: - file_contents = file.read() - except UnicodeDecodeError: - file_contents = '' - error = 'UnicodeDecodeError' - - self.write({ - 'content': file_contents, - 'error': error - }) + self.write(helper.get_os_understandable_path(path) + '\n' + + helper.generate_zip_tree(path)) self.finish() - elif page == "get_tree": + elif page == "get_zip_dir": + path = self.get_argument('path', None) + + self.write(helper.get_os_understandable_path(path) + '\n' + + helper.generate_zip_dir(path)) + self.finish() + + elif page == "get_backup_tree": server_id = self.get_argument('id', None) + folder = self.get_argument('path', None) - if not self.check_server_id(server_id, 'get_tree'): return - else: server_id = bleach.clean(server_id) + output = "" - self.write(self.controller.servers.get_server_data_by_id(server_id)['path'] + '\n' + - helper.generate_tree(self.controller.servers.get_server_data_by_id(server_id)['path'])) + dir_list = [] + unsorted_files = [] + file_list = os.listdir(folder) + for item in file_list: + if os.path.isdir(os.path.join(folder, item)): + dir_list.append(item) + else: + unsorted_files.append(item) + file_list = sorted(dir_list, key=str.casefold) + sorted(unsorted_files, key=str.casefold) + output += \ + f"""
          """\ + + for raw_filename in file_list: + filename = html.escape(raw_filename) + rel = os.path.join(folder, raw_filename) + dpath = os.path.join(folder, filename) + if str(dpath) in self.controller.management.get_excluded_backup_dirs(server_id): + if os.path.isdir(rel): + output += \ + f"""
        • + \n
          + + + + + {filename} + +
        • + \n"""\ + + else: + output += f"""
        • + {filename}
        • """ + + else: + if os.path.isdir(rel): + output += \ + f"""
        • + \n
          + + + + + {filename} + +
        • + \n"""\ + + else: + output += f"""
        • + {filename}
        • """ + self.write(helper.get_os_understandable_path(folder) + '\n' + + output) + self.finish() + + elif page == "get_backup_dir": + server_id = self.get_argument('id', None) + folder = self.get_argument('path', None) + output = "" + + dir_list = [] + unsorted_files = [] + file_list = os.listdir(folder) + for item in file_list: + if os.path.isdir(os.path.join(folder, item)): + dir_list.append(item) + else: + unsorted_files.append(item) + file_list = sorted(dir_list, key=str.casefold) + sorted(unsorted_files, key=str.casefold) + output += \ + f"""
            """\ + + for raw_filename in file_list: + filename = html.escape(raw_filename) + rel = os.path.join(folder, raw_filename) + dpath = os.path.join(folder, filename) + if str(dpath) in self.controller.management.get_excluded_backup_dirs(server_id): + if os.path.isdir(rel): + output += \ + f"""
          • + \n
            + + + + + {filename} + +
          • """\ + + else: + output += f"""
          • + {filename}
          • """ + + else: + if os.path.isdir(rel): + output += \ + f"""
          • + \n
            + + + + + {filename} + +
          • """\ + + else: + output += f"""
          • + {filename}
          • """ + + self.write(helper.get_os_understandable_path(folder) + '\n' + + output) + self.finish() + + elif page == "get_dir": + server_id = self.get_argument('id', None) + path = self.get_argument('path', None) + + if not self.check_server_id(server_id, 'get_tree'): + return + else: + server_id = bleach.clean(server_id) + + if helper.validate_traversal(self.controller.servers.get_server_data_by_id(server_id)['path'], path): + self.write(helper.get_os_understandable_path(path) + '\n' + + helper.generate_dir(path)) self.finish() @tornado.web.authenticated def post(self, page): - user_data = json.loads(self.get_secure_cookie("user_data")) - error = bleach.clean(self.get_argument('error', "WTF Error!")) + api_key, _, exec_user = self.current_user + superuser = exec_user['superuser'] + if api_key is not None: + superuser = superuser and api_key.superuser - page_data = { - 'user_data': user_data, - 'error': error - } + server_id = self.get_argument('id', None) + + permissions = { + 'Commands': Enum_Permissions_Server.Commands, + 'Terminal': Enum_Permissions_Server.Terminal, + 'Logs': Enum_Permissions_Server.Logs, + 'Schedule': Enum_Permissions_Server.Schedule, + 'Backup': Enum_Permissions_Server.Backup, + 'Files': Enum_Permissions_Server.Files, + 'Config': Enum_Permissions_Server.Config, + 'Players': Enum_Permissions_Server.Players, + } + user_perms = self.controller.server_perms.get_user_id_permissions_list(exec_user['user_id'], server_id) if page == "send_command": command = self.get_body_argument('command', default=None, strip=True) - server_id = self.get_argument('id') + server_id = self.get_argument('id', None) if server_id is None: logger.warning("Server ID not found in send_command ajax call") @@ -150,182 +286,213 @@ class AjaxHandler(BaseHandler): srv_obj = self.controller.get_server_obj(server_id) + if command == srv_obj.settings['stop_command']: + logger.info("Stop command detected as terminal input - intercepting." + + f"Starting Crafty's stop process for server with id: {server_id}") + self.controller.management.send_command(exec_user['user_id'], server_id, self.get_remote_ip(), 'stop_server') + command = None + elif command == 'restart': + logger.info("Restart command detected as terminal input - intercepting." + + f"Starting Crafty's stop process for server with id: {server_id}") + self.controller.management.send_command(exec_user['user_id'], server_id, self.get_remote_ip(), 'restart_server') + command = None if command: if srv_obj.check_running(): srv_obj.send_command(command) - self.controller.management.add_to_audit_log(user_data['user_id'], "Sent command to {} terminal: {}".format(self.controller.servers.get_server_friendly_name(server_id), command), server_id, self.get_remote_ip()) + self.controller.management.add_to_audit_log(exec_user['user_id'], + f"Sent command to {self.controller.servers.get_server_friendly_name(server_id)} terminal: {command}", + server_id, + self.get_remote_ip()) - elif page == "create_file": - file_parent = self.get_body_argument('file_parent', default=None, strip=True) - file_name = self.get_body_argument('file_name', default=None, strip=True) - file_path = os.path.join(file_parent, file_name) - server_id = self.get_argument('id', None) - - if not self.check_server_id(server_id, 'create_file'): return - else: server_id = bleach.clean(server_id) - - if not helper.in_path(self.controller.servers.get_server_data_by_id(server_id)['path'], file_path) \ - or helper.check_file_exists(os.path.abspath(file_path)): - logger.warning("Invalid path in create_file ajax call ({})".format(file_path)) - console.warning("Invalid path in create_file ajax call ({})".format(file_path)) - return - - # Create the file by opening it - with open(file_path, 'w') as file_object: - file_object.close() - - elif page == "create_dir": - dir_parent = self.get_body_argument('dir_parent', default=None, strip=True) - dir_name = self.get_body_argument('dir_name', default=None, strip=True) - dir_path = os.path.join(dir_parent, dir_name) - server_id = self.get_argument('id', None) - - if not self.check_server_id(server_id, 'create_dir'): return - else: server_id = bleach.clean(server_id) - - if not helper.in_path(self.controller.servers.get_server_data_by_id(server_id)['path'], dir_path) \ - or helper.check_path_exists(os.path.abspath(dir_path)): - logger.warning("Invalid path in create_dir ajax call ({})".format(dir_path)) - console.warning("Invalid path in create_dir ajax call ({})".format(dir_path)) - return - # Create the directory - os.mkdir(dir_path) - - elif page == "unzip_file": - server_id = self.get_argument('id', None) - path = self.get_argument('path', None) - helper.unzipFile(path) - self.redirect("/panel/server_detail?id={}&subpage=files".format(server_id)) + elif page == "send_order": + self.controller.users.update_server_order(exec_user['user_id'], bleach.clean(self.get_argument('order'))) return + elif page == "clear_comms": + if exec_user['superuser']: + self.controller.clear_unexecuted_commands() + return + elif page == "kill": + if not permissions['Commands'] in user_perms: + if not superuser: + self.redirect("/panel/error?error=Unauthorized access to Commands") + return server_id = self.get_argument('id', None) svr = self.controller.get_server_obj(server_id) try: svr.kill() except Exception as e: - logger.error("Could not find PID for requested termsig. Full error: {}".format(e)) + logger.error(f"Could not find PID for requested termsig. Full error: {e}") return + elif page == "eula": + server_id = self.get_argument('id', None) + svr = self.controller.get_server_obj(server_id) + svr.agree_eula(exec_user['user_id']) + + elif page == "restore_backup": + if not permissions['Backup'] in user_perms: + if not superuser: + self.redirect("/panel/error?error=Unauthorized access to Backups") + return + server_id = bleach.clean(self.get_argument('id', None)) + zip_name = bleach.clean(self.get_argument('zip_file', None)) + svr_obj = self.controller.servers.get_server_obj(server_id) + server_data = self.controller.servers.get_server_data_by_id(server_id) + if server_data['type'] == 'minecraft-java': + backup_path = svr_obj.backup_path + if helper.validate_traversal(backup_path, zip_name): + tempDir = helper.unzip_backup_archive(backup_path, zip_name) + new_server = self.controller.import_zip_server(svr_obj.server_name, + tempDir, + server_data['executable'], + '1', '2', + server_data['server_port']) + new_server_id = new_server + new_server = self.controller.get_server_data(new_server) + self.controller.rename_backup_dir(server_id, new_server_id, new_server['server_uuid']) + self.controller.remove_server(server_id, True) + self.redirect('/panel/dashboard') + + else: + backup_path = svr_obj.backup_path + if helper.validate_traversal(backup_path, zip_name): + tempDir = helper.unzip_backup_archive(backup_path, zip_name) + new_server = self.controller.import_bedrock_zip_server(svr_obj.server_name, + tempDir, + server_data['executable'], + server_data['server_port']) + new_server_id = new_server + new_server = self.controller.get_server_data(new_server) + self.controller.rename_backup_dir(server_id, new_server_id, new_server['server_uuid']) + self.controller.remove_server(server_id, True) + self.redirect('/panel/dashboard') + + elif page == "unzip_server": + path = self.get_argument('path', None) + if helper.check_file_exists(path): + helper.unzipServer(path, exec_user['user_id']) + else: + user_id = exec_user['user_id'] + if user_id: + time.sleep(5) + user_lang = self.controller.users.get_user_lang_by_id(user_id) + websocket_helper.broadcast_user(user_id, 'send_start_error',{ + 'error': translation.translate('error', 'no-file', user_lang) + }) + return + + elif page == "backup_select": + path = self.get_argument('path', None) + helper.backup_select(path, exec_user['user_id']) + return + @tornado.web.authenticated def delete(self, page): - if page == "del_file": - file_path = self.get_body_argument('file_path', default=None, strip=True) + api_key, _, exec_user = self.current_user + superuser = exec_user['superuser'] + if api_key is not None: + superuser = superuser and api_key.superuser + + server_id = self.get_argument('id', None) + + + + permissions = { + 'Commands': Enum_Permissions_Server.Commands, + 'Terminal': Enum_Permissions_Server.Terminal, + 'Logs': Enum_Permissions_Server.Logs, + 'Schedule': Enum_Permissions_Server.Schedule, + 'Backup': Enum_Permissions_Server.Backup, + 'Files': Enum_Permissions_Server.Files, + 'Config': Enum_Permissions_Server.Config, + 'Players': Enum_Permissions_Server.Players, + } + user_perms = self.controller.server_perms.get_user_id_permissions_list(exec_user['user_id'], server_id) + if page == "del_task": + if not permissions['Schedule'] in user_perms: + self.redirect("/panel/error?error=Unauthorized access to Tasks") + else: + sch_id = self.get_argument('schedule_id', '-404') + self.tasks_manager.remove_job(sch_id) + + if page == "del_backup": + if not permissions['Backup'] in user_perms: + if not superuser: + self.redirect("/panel/error?error=Unauthorized access to Backups") + return + file_path = helper.get_os_understandable_path(self.get_body_argument('file_path', default=None, strip=True)) server_id = self.get_argument('id', None) - if os.name == "nt": - file_path = file_path.replace('/', "\\") + console.warning(f"Delete {file_path} for server {server_id}") - console.warning("delete {} for server {}".format(file_path, server_id)) - - if not self.check_server_id(server_id, 'del_file'): + if not self.check_server_id(server_id, 'del_backup'): return else: server_id = bleach.clean(server_id) server_info = self.controller.servers.get_server_data_by_id(server_id) - if not (helper.in_path(server_info['path'], file_path) \ - or helper.in_path(server_info['backup_path'], file_path)) \ + if not (helper.in_path(helper.get_os_understandable_path(server_info['path']), file_path) \ + or helper.in_path(helper.get_os_understandable_path(server_info['backup_path']), file_path)) \ or not helper.check_file_exists(os.path.abspath(file_path)): - logger.warning("Invalid path in del_file ajax call ({})".format(file_path)) - console.warning("Invalid path in del_file ajax call ({})".format(file_path)) + logger.warning(f"Invalid path in del_backup ajax call ({file_path})") + console.warning(f"Invalid path in del_backup ajax call ({file_path})") return # Delete the file - os.remove(file_path) - - elif page == "del_dir": - dir_path = self.get_body_argument('dir_path', default=None, strip=True) - server_id = self.get_argument('id', None) - - console.warning("delete {} for server {}".format(dir_path, server_id)) - - if not self.check_server_id(server_id, 'del_dir'): return - else: server_id = bleach.clean(server_id) - - server_info = self.controller.servers.get_server_data_by_id(server_id) - if not helper.in_path(server_info['path'], dir_path) \ - or not helper.check_path_exists(os.path.abspath(dir_path)): - logger.warning("Invalid path in del_file ajax call ({})".format(dir_path)) - console.warning("Invalid path in del_file ajax call ({})".format(dir_path)) - return - - # Delete the directory - # os.rmdir(dir_path) # Would only remove empty directories - shutil.rmtree(dir_path) # Removes also when there are contents + if helper.validate_traversal(helper.get_os_understandable_path(server_info['backup_path']), file_path): + os.remove(file_path) elif page == "delete_server": + if not permissions['Config'] in user_perms: + if not superuser: + self.redirect("/panel/error?error=Unauthorized access to Config") + return server_id = self.get_argument('id', None) - logger.info( - "Removing server from panel for server: {}".format(self.controller.servers.get_server_friendly_name(server_id))) + logger.info(f"Removing server from panel for server: {self.controller.servers.get_server_friendly_name(server_id)}") + + server_data = self.controller.get_server_data(server_id) + server_name = server_data['server_name'] + + self.controller.management.add_to_audit_log(exec_user['user_id'], + f"Deleted server {server_id} named {server_name}", + server_id, + self.get_remote_ip()) + + self.tasks_manager.remove_all_server_tasks(server_id) self.controller.remove_server(server_id, False) elif page == "delete_server_files": + if not permissions['Config'] in user_perms: + if not superuser: + self.redirect("/panel/error?error=Unauthorized access to Config") + return server_id = self.get_argument('id', None) - logger.info( - "Removing server and all associated files for server: {}".format(self.controller.servers.get_server_friendly_name(server_id))) + logger.info(f"Removing server and all associated files for server: {self.controller.servers.get_server_friendly_name(server_id)}") + + server_data = self.controller.get_server_data(server_id) + server_name = server_data['server_name'] + + self.controller.management.add_to_audit_log(exec_user['user_id'], + f"Deleted server {server_id} named {server_name}", + server_id, + self.get_remote_ip()) + + self.tasks_manager.remove_all_server_tasks(server_id) self.controller.remove_server(server_id, True) - @tornado.web.authenticated - def put(self, page): - if page == "save_file": - file_contents = self.get_body_argument('file_contents', default=None, strip=True) - file_path = self.get_body_argument('file_path', default=None, strip=True) - server_id = self.get_argument('id', None) - - if not self.check_server_id(server_id, 'save_file'): return - else: server_id = bleach.clean(server_id) - - if not helper.in_path(self.controller.servers.get_server_data_by_id(server_id)['path'], file_path)\ - or not helper.check_file_exists(os.path.abspath(file_path)): - logger.warning("Invalid path in save_file ajax call ({})".format(file_path)) - console.warning("Invalid path in save_file ajax call ({})".format(file_path)) - return - - # Open the file in write mode and store the content in file_object - with open(file_path, 'w') as file_object: - file_object.write(file_contents) - - elif page == "rename_item": - item_path = self.get_body_argument('item_path', default=None, strip=True) - new_item_name = self.get_body_argument('new_item_name', default=None, strip=True) - server_id = self.get_argument('id', None) - - if not self.check_server_id(server_id, 'rename_item'): return - else: server_id = bleach.clean(server_id) - - if item_path is None or new_item_name is None: - logger.warning("Invalid path(s) in rename_item ajax call") - console.warning("Invalid path(s) in rename_item ajax call") - return - - if not helper.in_path(self.controller.servers.get_server_data_by_id(server_id)['path'], item_path) \ - or not helper.check_path_exists(os.path.abspath(item_path)): - logger.warning("Invalid old name path in rename_item ajax call ({})".format(server_id)) - console.warning("Invalid old name path in rename_item ajax call ({})".format(server_id)) - return - - new_item_path = os.path.join(os.path.split(item_path)[0], new_item_name) - - if not helper.in_path(self.controller.servers.get_server_data_by_id(server_id)['path'], new_item_path) \ - or helper.check_path_exists(os.path.abspath(new_item_path)): - logger.warning("Invalid new name path in rename_item ajax call ({})".format(server_id)) - console.warning("Invalid new name path in rename_item ajax call ({})".format(server_id)) - return - - # RENAME - os.rename(item_path, new_item_path) def check_server_id(self, server_id, page_name): if server_id is None: - logger.warning("Server ID not defined in {} ajax call ({})".format(page_name, server_id)) - console.warning("Server ID not defined in {} ajax call ({})".format(page_name, server_id)) + logger.warning(f"Server ID not defined in {page_name} ajax call ({server_id})") + console.warning(f"Server ID not defined in {page_name} ajax call ({server_id})") return else: server_id = bleach.clean(server_id) # does this server id exist? if not self.controller.servers.server_id_exists(server_id): - logger.warning("Server ID not found in {} ajax call ({})".format(page_name, server_id)) - console.warning("Server ID not found in {} ajax call ({})".format(page_name, server_id)) + logger.warning(f"Server ID not found in {page_name} ajax call ({server_id})") + console.warning(f"Server ID not found in {page_name} ajax call ({server_id})") return return True diff --git a/app/classes/web/api_handler.py b/app/classes/web/api_handler.py index 05f23c95..1374d929 100644 --- a/app/classes/web/api_handler.py +++ b/app/classes/web/api_handler.py @@ -1,57 +1,65 @@ -import os -import secrets -import threading -import tornado.web -import tornado.escape import logging +import re from app.classes.web.base_handler import BaseHandler -log = logging.getLogger(__name__) - +logger = logging.getLogger(__name__) +bearer_pattern = re.compile(r'^Bearer', flags=re.IGNORECASE) class ApiHandler(BaseHandler): - + def return_response(self, status: int, data: dict): - # Define a standardized response + # Define a standardized response self.set_status(status) self.write(data) - + def access_denied(self, user, reason=''): - if reason: reason = ' because ' + reason - log.info("User %s from IP %s was denied access to the API route " + self.request.path + reason, user, self.get_remote_ip()) + if reason: + reason = ' because ' + reason + logger.info("User %s from IP %s was denied access to the API route " + self.request.path + reason, user, self.get_remote_ip()) self.finish(self.return_response(403, { 'error':'ACCESS_DENIED', 'info':'You were denied access to the requested resource' })) - + def authenticate_user(self) -> bool: try: - log.debug("Searching for specified token") - # TODO: YEET THIS - user_data = self.controller.users.get_user_by_api_token(self.get_argument('token')) - log.debug("Checking results") + logger.debug("Searching for specified token") + + api_token = self.get_argument('token', '') + if api_token is None and self.request.headers.get('Authorization'): + api_token = bearer_pattern.sub('', self.request.headers.get('Authorization')) + elif api_token is None: + api_token = self.get_cookie('token') + user_data = self.controller.users.get_user_by_api_token(api_token) + + logger.debug("Checking results") if user_data: # Login successful! Check perms - log.info("User {} has authenticated to API".format(user_data['username'])) - # TODO: Role check + logger.info(f"User {user_data['username']} has authenticated to API") + # TODO: Role check return True # This is to set the "authenticated" else: logging.debug("Auth unsuccessful") self.access_denied("unknown", "the user provided an invalid token") - return + return False except Exception as e: - log.warning("An error occured while authenticating an API user: %s", e) - self.access_denied("unknown"), "an error occured while authenticating the user" - return + logger.warning("An error occured while authenticating an API user: %s", e) + self.finish(self.return_response(403, { + 'error':'ACCESS_DENIED', + 'info':'An error occured while authenticating the user' + })) + return False class ServersStats(ApiHandler): def get(self): """Get details about all servers""" authenticated = self.authenticate_user() - if not authenticated: return + if not authenticated: + return + # Get server stats # TODO Check perms self.finish(self.write({"servers": self.controller.stats.get_servers_stats()})) @@ -61,7 +69,9 @@ class NodeStats(ApiHandler): def get(self): """Get stats for particular node""" authenticated = self.authenticate_user() - if not authenticated: return + if not authenticated: + return + # Get node stats node_stats = self.controller.stats.get_node_stats() node_stats.pop("servers") diff --git a/app/classes/web/base_handler.py b/app/classes/web/base_handler.py index e4046d1e..84cb1e4b 100644 --- a/app/classes/web/base_handler.py +++ b/app/classes/web/base_handler.py @@ -1,23 +1,31 @@ import logging -import tornado.web -import bleach from typing import ( Union, List, - Optional + Optional, Tuple, Dict, Any ) +from app.classes.models.users import ApiKeys +from app.classes.shared.authentication import authentication from app.classes.shared.main_controller import Controller +from app.classes.shared.helpers import helper + +try: + import tornado.web + import bleach + +except ModuleNotFoundError as e: + helper.auto_installer_fix(e) logger = logging.getLogger(__name__) - class BaseHandler(tornado.web.RequestHandler): nobleach = {bool, type(None)} redactables = ("pass", "api") - def initialize(self, controller : Controller = None, tasks_manager=None, translator=None): + # noinspection PyAttributeOutsideInit + def initialize(self, controller: Controller = None, tasks_manager=None, translator=None): self.controller = controller self.tasks_manager = tasks_manager self.translator = translator @@ -28,16 +36,17 @@ class BaseHandler(tornado.web.RequestHandler): self.request.remote_ip return remote_ip - def get_current_user(self): - return self.get_secure_cookie("user", max_age_days=1) + current_user: Optional[Tuple[Optional[ApiKeys], Dict[str, Any], Dict[str, Any]]] + def get_current_user(self) -> Optional[Tuple[Optional[ApiKeys], Dict[str, Any], Dict[str, Any]]]: + return authentication.check(self.get_cookie("token")) def autobleach(self, name, text): for r in self.redactables: if r in name: - logger.debug("Auto-bleaching {}: {}".format(name, "[**REDACTED**]")) + logger.debug(f"Auto-bleaching {name}: [**REDACTED**]") break else: - logger.debug("Auto-bleaching {}: {}".format(name, text)) + logger.debug(f"Auto-bleaching {name}: {text}") if type(text) in self.nobleach: logger.debug("Auto-bleaching - bypass type") return text @@ -54,7 +63,8 @@ class BaseHandler(tornado.web.RequestHandler): return self.autobleach(name, arg) def get_arguments(self, name: str, strip: bool = True) -> List[str]: - assert isinstance(strip, bool) + if not isinstance(strip, bool): + raise AssertionError args = self._get_arguments(name, self.request.arguments, strip) args_ret = [] for arg in args: diff --git a/app/classes/web/default_handler.py b/app/classes/web/default_handler.py index 0b8e45e1..868c4c42 100644 --- a/app/classes/web/default_handler.py +++ b/app/classes/web/default_handler.py @@ -4,10 +4,10 @@ from app.classes.web.base_handler import BaseHandler logger = logging.getLogger(__name__) - class DefaultHandler(BaseHandler): # Override prepare() instead of get() to cover all possible HTTP methods. + # pylint: disable=arguments-differ def prepare(self, page=None): if page is not None: self.set_status(404) @@ -20,4 +20,3 @@ class DefaultHandler(BaseHandler): "/public/login", #translate=self.translator.translate, ) - diff --git a/app/classes/web/file_handler.py b/app/classes/web/file_handler.py new file mode 100644 index 00000000..3f84fc51 --- /dev/null +++ b/app/classes/web/file_handler.py @@ -0,0 +1,414 @@ +import os +import logging + +from app.classes.models.server_permissions import Enum_Permissions_Server +from app.classes.shared.console import console +from app.classes.shared.helpers import helper +from app.classes.shared.file_helpers import file_helper +from app.classes.web.base_handler import BaseHandler + +try: + import bleach + import tornado.web + import tornado.escape + +except ModuleNotFoundError as e: + helper.auto_installer_fix(e) + +logger = logging.getLogger(__name__) + +class FileHandler(BaseHandler): + + def render_page(self, template, page_data): + self.render( + template, + data=page_data, + translate=self.translator.translate, + ) + + @tornado.web.authenticated + def get(self, page): + api_key, _, exec_user = self.current_user + superuser = exec_user['superuser'] + if api_key is not None: + superuser = superuser and api_key.superuser + + server_id = self.get_argument('id', None) + + permissions = { + 'Commands': Enum_Permissions_Server.Commands, + 'Terminal': Enum_Permissions_Server.Terminal, + 'Logs': Enum_Permissions_Server.Logs, + 'Schedule': Enum_Permissions_Server.Schedule, + 'Backup': Enum_Permissions_Server.Backup, + 'Files': Enum_Permissions_Server.Files, + 'Config': Enum_Permissions_Server.Config, + 'Players': Enum_Permissions_Server.Players, + } + user_perms = self.controller.server_perms.get_user_id_permissions_list(exec_user['user_id'], server_id) + + if page == "get_file": + if not permissions['Files'] in user_perms: + if not superuser: + self.redirect("/panel/error?error=Unauthorized access to Files") + return + file_path = helper.get_os_understandable_path(self.get_argument('file_path', None)) + + if not self.check_server_id(server_id, 'get_file'): + return + else: + server_id = bleach.clean(server_id) + + if not helper.in_path(helper.get_os_understandable_path(self.controller.servers.get_server_data_by_id(server_id)['path']), file_path)\ + or not helper.check_file_exists(os.path.abspath(file_path)): + logger.warning(f"Invalid path in get_file file file ajax call ({file_path})") + console.warning(f"Invalid path in get_file file file ajax call ({file_path})") + return + + + error = None + + try: + with open(file_path, encoding='utf-8') as file: + file_contents = file.read() + except UnicodeDecodeError: + file_contents = '' + error = 'UnicodeDecodeError' + + self.write({ + 'content': file_contents, + 'error': error + }) + self.finish() + + elif page == "get_tree": + if not permissions['Files'] in user_perms: + if not superuser: + self.redirect("/panel/error?error=Unauthorized access to Files") + return + path = self.get_argument('path', None) + + if not self.check_server_id(server_id, 'get_tree'): + return + else: + server_id = bleach.clean(server_id) + + if helper.validate_traversal(self.controller.servers.get_server_data_by_id(server_id)['path'], path): + self.write(helper.get_os_understandable_path(path) + '\n' + + helper.generate_tree(path)) + self.finish() + + elif page == "get_dir": + if not permissions['Files'] in user_perms: + if not superuser: + self.redirect("/panel/error?error=Unauthorized access to Files") + return + path = self.get_argument('path', None) + + if not self.check_server_id(server_id, 'get_tree'): + return + else: + server_id = bleach.clean(server_id) + + if helper.validate_traversal(self.controller.servers.get_server_data_by_id(server_id)['path'], path): + self.write(helper.get_os_understandable_path(path) + '\n' + + helper.generate_dir(path)) + self.finish() + + @tornado.web.authenticated + def post(self, page): + api_key, _, exec_user = self.current_user + superuser = exec_user['superuser'] + if api_key is not None: + superuser = superuser and api_key.superuser + + server_id = self.get_argument('id', None) + + permissions = { + 'Commands': Enum_Permissions_Server.Commands, + 'Terminal': Enum_Permissions_Server.Terminal, + 'Logs': Enum_Permissions_Server.Logs, + 'Schedule': Enum_Permissions_Server.Schedule, + 'Backup': Enum_Permissions_Server.Backup, + 'Files': Enum_Permissions_Server.Files, + 'Config': Enum_Permissions_Server.Config, + 'Players': Enum_Permissions_Server.Players, + } + user_perms = self.controller.server_perms.get_user_id_permissions_list(exec_user['user_id'], server_id) + + if page == "create_file": + if not permissions['Files'] in user_perms: + if not superuser: + self.redirect("/panel/error?error=Unauthorized access to Files") + return + file_parent = helper.get_os_understandable_path(self.get_body_argument('file_parent', default=None, strip=True)) + file_name = self.get_body_argument('file_name', default=None, strip=True) + file_path = os.path.join(file_parent, file_name) + + if not self.check_server_id(server_id, 'create_file'): + return + else: + server_id = bleach.clean(server_id) + + if not helper.in_path(helper.get_os_understandable_path(self.controller.servers.get_server_data_by_id(server_id)['path']), file_path) \ + or helper.check_file_exists(os.path.abspath(file_path)): + logger.warning(f"Invalid path in create_file file ajax call ({file_path})") + console.warning(f"Invalid path in create_file file ajax call ({file_path})") + return + + # Create the file by opening it + with open(file_path, 'w', encoding='utf-8') as file_object: + file_object.close() + + elif page == "create_dir": + if not permissions['Files'] in user_perms: + if not superuser: + self.redirect("/panel/error?error=Unauthorized access to Files") + return + dir_parent = helper.get_os_understandable_path(self.get_body_argument('dir_parent', default=None, strip=True)) + dir_name = self.get_body_argument('dir_name', default=None, strip=True) + dir_path = os.path.join(dir_parent, dir_name) + + if not self.check_server_id(server_id, 'create_dir'): + return + else: + server_id = bleach.clean(server_id) + + if not helper.in_path(helper.get_os_understandable_path(self.controller.servers.get_server_data_by_id(server_id)['path']), dir_path) \ + or helper.check_path_exists(os.path.abspath(dir_path)): + logger.warning(f"Invalid path in create_dir file ajax call ({dir_path})") + console.warning(f"Invalid path in create_dir file ajax call ({dir_path})") + return + # Create the directory + os.mkdir(dir_path) + + elif page == "unzip_file": + if not permissions['Files'] in user_perms: + if not superuser: + self.redirect("/panel/error?error=Unauthorized access to Files") + return + path = helper.get_os_understandable_path(self.get_argument('path', None)) + helper.unzipFile(path) + self.redirect(f"/panel/server_detail?id={server_id}&subpage=files") + return + + + @tornado.web.authenticated + def delete(self, page): + api_key, _, exec_user = self.current_user + superuser = exec_user['superuser'] + if api_key is not None: + superuser = superuser and api_key.superuser + + server_id = self.get_argument('id', None) + + permissions = { + 'Commands': Enum_Permissions_Server.Commands, + 'Terminal': Enum_Permissions_Server.Terminal, + 'Logs': Enum_Permissions_Server.Logs, + 'Schedule': Enum_Permissions_Server.Schedule, + 'Backup': Enum_Permissions_Server.Backup, + 'Files': Enum_Permissions_Server.Files, + 'Config': Enum_Permissions_Server.Config, + 'Players': Enum_Permissions_Server.Players, + } + user_perms = self.controller.server_perms.get_user_id_permissions_list(exec_user['user_id'], server_id) + if page == "del_file": + if not permissions['Files'] in user_perms: + if not superuser: + self.redirect("/panel/error?error=Unauthorized access to Files") + return + file_path = helper.get_os_understandable_path(self.get_body_argument('file_path', default=None, strip=True)) + + console.warning(f"Delete {file_path} for server {server_id}") + + if not self.check_server_id(server_id, 'del_file'): + return + else: server_id = bleach.clean(server_id) + + server_info = self.controller.servers.get_server_data_by_id(server_id) + if not (helper.in_path(helper.get_os_understandable_path(server_info['path']), file_path) \ + or helper.in_path(helper.get_os_understandable_path(server_info['backup_path']), file_path)) \ + or not helper.check_file_exists(os.path.abspath(file_path)): + logger.warning(f"Invalid path in del_file file ajax call ({file_path})") + console.warning(f"Invalid path in del_file file ajax call ({file_path})") + return + + # Delete the file + file_helper.del_file(file_path) + + elif page == "del_dir": + if not permissions['Files'] in user_perms: + if not superuser: + self.redirect("/panel/error?error=Unauthorized access to Files") + return + dir_path = helper.get_os_understandable_path(self.get_body_argument('dir_path', default=None, strip=True)) + + console.warning(f"Delete {dir_path} for server {server_id}") + + if not self.check_server_id(server_id, 'del_dir'): + return + else: + server_id = bleach.clean(server_id) + + server_info = self.controller.servers.get_server_data_by_id(server_id) + if not helper.in_path(helper.get_os_understandable_path(server_info['path']), dir_path) \ + or not helper.check_path_exists(os.path.abspath(dir_path)): + logger.warning(f"Invalid path in del_file file ajax call ({dir_path})") + console.warning(f"Invalid path in del_file file ajax call ({dir_path})") + return + + # Delete the directory + # os.rmdir(dir_path) # Would only remove empty directories + if helper.validate_traversal(helper.get_os_understandable_path(server_info['path']), dir_path): + # Removes also when there are contents + file_helper.del_dirs(dir_path) + + @tornado.web.authenticated + def put(self, page): + api_key, _, exec_user = self.current_user + superuser = exec_user['superuser'] + if api_key is not None: + superuser = superuser and api_key.superuser + + server_id = self.get_argument('id', None) + permissions = { + 'Commands': Enum_Permissions_Server.Commands, + 'Terminal': Enum_Permissions_Server.Terminal, + 'Logs': Enum_Permissions_Server.Logs, + 'Schedule': Enum_Permissions_Server.Schedule, + 'Backup': Enum_Permissions_Server.Backup, + 'Files': Enum_Permissions_Server.Files, + 'Config': Enum_Permissions_Server.Config, + 'Players': Enum_Permissions_Server.Players, + } + user_perms = self.controller.server_perms.get_user_id_permissions_list(exec_user['user_id'], server_id) + if page == "save_file": + if not permissions['Files'] in user_perms: + if not superuser: + self.redirect("/panel/error?error=Unauthorized access to Files") + return + file_contents = self.get_body_argument('file_contents', default=None, strip=True) + file_path = helper.get_os_understandable_path(self.get_body_argument('file_path', default=None, strip=True)) + + if not self.check_server_id(server_id, 'save_file'): + return + else: + server_id = bleach.clean(server_id) + + if not helper.in_path(helper.get_os_understandable_path(self.controller.servers.get_server_data_by_id(server_id)['path']), file_path)\ + or not helper.check_file_exists(os.path.abspath(file_path)): + logger.warning(f"Invalid path in save_file file ajax call ({file_path})") + console.warning(f"Invalid path in save_file file ajax call ({file_path})") + return + + # Open the file in write mode and store the content in file_object + with open(file_path, 'w', encoding='utf-8') as file_object: + file_object.write(file_contents) + + elif page == "rename_file": + if not permissions['Files'] in user_perms: + if not superuser: + self.redirect("/panel/error?error=Unauthorized access to Files") + return + item_path = helper.get_os_understandable_path(self.get_body_argument('item_path', default=None, strip=True)) + new_item_name = self.get_body_argument('new_item_name', default=None, strip=True) + + if not self.check_server_id(server_id, 'rename_file'): + return + else: + server_id = bleach.clean(server_id) + + if item_path is None or new_item_name is None: + logger.warning("Invalid path(s) in rename_file file ajax call") + console.warning("Invalid path(s) in rename_file file ajax call") + return + + if not helper.in_path(helper.get_os_understandable_path(self.controller.servers.get_server_data_by_id(server_id)['path']), item_path) \ + or not helper.check_path_exists(os.path.abspath(item_path)): + logger.warning(f"Invalid old name path in rename_file file ajax call ({server_id})") + console.warning(f"Invalid old name path in rename_file file ajax call ({server_id})") + return + + new_item_path = os.path.join(os.path.split(item_path)[0], new_item_name) + + if not helper.in_path(helper.get_os_understandable_path(self.controller.servers.get_server_data_by_id(server_id)['path']), + new_item_path) \ + or helper.check_path_exists(os.path.abspath(new_item_path)): + logger.warning(f"Invalid new name path in rename_file file ajax call ({server_id})") + console.warning(f"Invalid new name path in rename_file file ajax call ({server_id})") + return + + # RENAME + os.rename(item_path, new_item_path) + + + @tornado.web.authenticated + def patch(self, page): + api_key, _, exec_user = self.current_user + superuser = exec_user['superuser'] + if api_key is not None: + superuser = superuser and api_key.superuser + + server_id = self.get_argument('id', None) + permissions = { + 'Commands': Enum_Permissions_Server.Commands, + 'Terminal': Enum_Permissions_Server.Terminal, + 'Logs': Enum_Permissions_Server.Logs, + 'Schedule': Enum_Permissions_Server.Schedule, + 'Backup': Enum_Permissions_Server.Backup, + 'Files': Enum_Permissions_Server.Files, + 'Config': Enum_Permissions_Server.Config, + 'Players': Enum_Permissions_Server.Players, + } + user_perms = self.controller.server_perms.get_user_id_permissions_list(exec_user['user_id'], server_id) + if page == "rename_file": + if not permissions['Files'] in user_perms: + if not superuser: + self.redirect("/panel/error?error=Unauthorized access to Files") + return + item_path = helper.get_os_understandable_path(self.get_body_argument('item_path', default=None, strip=True)) + new_item_name = self.get_body_argument('new_item_name', default=None, strip=True) + + if not self.check_server_id(server_id, 'rename_file'): + return + else: + server_id = bleach.clean(server_id) + + if item_path is None or new_item_name is None: + logger.warning("Invalid path(s) in rename_file file ajax call") + console.warning("Invalid path(s) in rename_file file ajax call") + return + + if not helper.in_path(helper.get_os_understandable_path(self.controller.servers.get_server_data_by_id(server_id)['path']), item_path) \ + or not helper.check_path_exists(os.path.abspath(item_path)): + logger.warning(f"Invalid old name path in rename_file file ajax call ({server_id})") + console.warning(f"Invalid old name path in rename_file file ajax call ({server_id})") + return + + new_item_path = os.path.join(os.path.split(item_path)[0], new_item_name) + + if not helper.in_path(helper.get_os_understandable_path(self.controller.servers.get_server_data_by_id(server_id)['path']), + new_item_path) \ + or helper.check_path_exists(os.path.abspath(new_item_path)): + logger.warning(f"Invalid new name path in rename_file file ajax call ({server_id})") + console.warning(f"Invalid new name path in rename_file file ajax call ({server_id})") + return + + # RENAME + os.rename(item_path, new_item_path) + + def check_server_id(self, server_id, page_name): + if server_id is None: + logger.warning(f"Server ID not defined in {page_name} file ajax call ({server_id})") + console.warning(f"Server ID not defined in {page_name} file ajax call ({server_id})") + return + else: + server_id = bleach.clean(server_id) + + # does this server id exist? + if not self.controller.servers.server_id_exists(server_id): + logger.warning(f"Server ID not found in {page_name} file ajax call ({server_id})") + console.warning(f"Server ID not found in {page_name} file ajax call ({server_id})") + return + return True diff --git a/app/classes/web/http_handler.py b/app/classes/web/http_handler.py index f8646de5..e3b03f57 100644 --- a/app/classes/web/http_handler.py +++ b/app/classes/web/http_handler.py @@ -1,25 +1,15 @@ -import sys -import json import logging -import tornado.web -import tornado.escape -import requests from app.classes.shared.helpers import helper from app.classes.web.base_handler import BaseHandler -from app.classes.shared.console import console -from app.classes.shared.main_models import Users, fn - -logger = logging.getLogger(__name__) try: - import bleach + import requests except ModuleNotFoundError as e: - logger.critical("Import Error: Unable to load {} module".format(e.name), exc_info=True) - console.critical("Import Error: Unable to load {} module".format(e.name)) - sys.exit(1) + helper.auto_installer_fix(e) +logger = logging.getLogger(__name__) class HTTPHandler(BaseHandler): def get(self): @@ -34,13 +24,13 @@ class HTTPHandler(BaseHandler): try: resp = requests.get(url + ":" + str(port)) resp.raise_for_status() - except Exception as err: + except Exception: port = db_port self.redirect(url+":"+str(port)) class HTTPHandlerPage(BaseHandler): - def get(self, page): + def get(self): url = str(self.request.host) port = 443 url_list = url.split(":") @@ -52,6 +42,6 @@ class HTTPHandlerPage(BaseHandler): try: resp = requests.get(url + ":" + str(port)) resp.raise_for_status() - except Exception as err: + except Exception: port = db_port - self.redirect(url+":"+str(port)) \ No newline at end of file + self.redirect(url+":"+str(port)) diff --git a/app/classes/web/http_handler_page.py b/app/classes/web/http_handler_page.py index 8b9e9c90..231da39b 100644 --- a/app/classes/web/http_handler_page.py +++ b/app/classes/web/http_handler_page.py @@ -1,49 +1,31 @@ -import sys -import json import logging -import tornado.web -import tornado.escape import requests from app.classes.shared.helpers import helper from app.classes.web.base_handler import BaseHandler -from app.classes.shared.console import console -from app.classes.shared.main_models import Users, fn logger = logging.getLogger(__name__) - -try: - import bleach - -except ModuleNotFoundError as e: - logger.critical("Import Error: Unable to load {} module".format(e.name), exc_info=True) - console.critical("Import Error: Unable to load {} module".format(e.name)) - sys.exit(1) - - class HTTPHandlerPage(BaseHandler): - def get(self, page): + def get(self): url = self.request.full_url port = 443 - print(url) if url[len(url)-1] == '/': url = url.strip(url[len(url)-1]) url_list = url.split('/') - print(url_list) if url_list[0] != "": primary_url = url_list[0] + ":"+str(port)+"/" - backup_url = url_list[0] + ":" +str(helper.get_setting["https_port"]) +"/" + backup_url = url_list[0] + ":" +str(helper.get_setting("https_port")) +"/" for i in range(len(url_list)-1): primary_url += url_list[i+1] backup_url += url_list[i+1] else: primary_url = url + str(port) - backup_url = url + str(helper.get_setting['https_port']) - + backup_url = url + str(helper.get_setting('https_port')) + try: resp = requests.get(primary_url) resp.raise_for_status() url = primary_url - except Exception as err: + except Exception: url = backup_url - self.redirect('https://'+url+':'+ str(port)) \ No newline at end of file + self.redirect('https://'+url+':'+ str(port)) diff --git a/app/classes/web/panel_handler.py b/app/classes/web/panel_handler.py index 1f6c5471..636ed36b 100644 --- a/app/classes/web/panel_handler.py +++ b/app/classes/web/panel_handler.py @@ -1,62 +1,239 @@ -from app.classes.shared.translation import Translation -import json -import logging -import tornado.web -import tornado.escape -import bleach import time import datetime import os +from typing import Dict, Any, Tuple +import json +import logging +import threading -from tornado import iostream - -from app.classes.shared.console import console -from app.classes.shared.main_models import Users, installer - -from app.classes.web.base_handler import BaseHandler - -from app.classes.models.servers import Servers from app.classes.models.server_permissions import Enum_Permissions_Server from app.classes.models.crafty_permissions import Enum_Permissions_Crafty - +from app.classes.models.management import management_helper +from app.classes.shared.authentication import authentication from app.classes.shared.helpers import helper +from app.classes.web.base_handler import BaseHandler + +try: + import bleach + import libgravatar + import requests + import tornado.web + import tornado.escape + from tornado import iostream + #TZLocal is set as a hidden import on win pipeline + from tzlocal import get_localzone + from cron_validator import CronValidator + +except ModuleNotFoundError as ex: + helper.auto_installer_fix(ex) logger = logging.getLogger(__name__) - class PanelHandler(BaseHandler): + def get_user_roles(self) -> Dict[str, list]: + user_roles = {} + for user in self.controller.users.get_all_users(): + user_roles_list = self.controller.users.get_user_roles_names(user.user_id) + # user_servers = self.controller.servers.get_authorized_servers(user.user_id) + user_roles[user.user_id] = user_roles_list + return user_roles + + def get_role_servers(self) -> set: + servers = set() + for server in self.controller.list_defined_servers(): + argument = int(float( + bleach.clean( + self.get_argument(f"server_{server['server_id']}_access", '0') + ) + )) + if argument: + servers.add(server['server_id']) + return servers + + def get_perms_quantity(self) -> Tuple[str, dict]: + permissions_mask: str = "000" + server_quantity: dict = {} + for permission in self.controller.crafty_perms.list_defined_crafty_permissions(): + argument = int(float(bleach.clean( + self.get_argument(f'permission_{permission.name}', '0') + ))) + if argument: + permissions_mask = self.controller.crafty_perms.set_permission(permissions_mask, permission, argument) + + q_argument = int(float( + bleach.clean( + self.get_argument(f'quantity_{permission.name}', '0') + ) + )) + if q_argument: + server_quantity[permission.name] = q_argument + else: + server_quantity[permission.name] = 0 + return permissions_mask, server_quantity + + def get_perms(self) -> str: + permissions_mask: str = "000" + for permission in self.controller.crafty_perms.list_defined_crafty_permissions(): + argument = self.get_argument(f'permission_{permission.name}', None) + if argument is not None: + permissions_mask = self.controller.crafty_perms.set_permission(permissions_mask, permission, + 1 if argument == '1' else 0) + return permissions_mask + + def get_perms_server(self) -> str: + permissions_mask = "00000000" + for permission in self.controller.server_perms.list_defined_permissions(): + argument = self.get_argument(f'permission_{permission.name}', None) + if argument is not None: + permissions_mask = self.controller.server_perms.set_permission(permissions_mask, permission, + 1 if argument == '1' else 0) + return permissions_mask + + def get_user_role_memberships(self) -> set: + roles = set() + for role in self.controller.roles.get_all_roles(): + if self.get_argument(f'role_{role.role_id}_membership', None) == '1': + roles.add(role.role_id) + return roles + + def download_file(self, name: str, file: str): + self.set_header('Content-Type', 'application/octet-stream') + self.set_header('Content-Disposition', f'attachment; filename={name}') + chunk_size = 1024 * 1024 * 4 # 4 MiB + + with open(file, 'rb') as f: + while True: + chunk = f.read(chunk_size) + if not chunk: + break + try: + self.write(chunk) # write the chunk to response + self.flush() # send the chunk to client + except iostream.StreamClosedError: + # this means the client has closed the connection + # so break the loop + break + finally: + # deleting the chunk is very important because + # if many clients are downloading files at the + # same time, the chunks in memory will keep + # increasing and will eat up the RAM + del chunk + + def check_server_id(self): + server_id = self.get_argument('id', None) + + api_key, _, exec_user = self.current_user + superuser = exec_user['superuser'] + + # Commented out because there is no server access control for API keys, they just inherit from the host user + # if api_key is not None: + # superuser = superuser and api_key.superuser + + if server_id is None: + self.redirect("/panel/error?error=Invalid Server ID") + return None + else: + # Does this server exist? + if not self.controller.servers.server_id_exists(server_id): + self.redirect("/panel/error?error=Invalid Server ID") + return None + + # Does the user have permission? + if not superuser: # TODO: Figure out a better solution + if api_key is not None: + if not self.controller.servers.server_id_authorized_api_key(server_id, api_key): + print(f'API key {api_key.name} (id: {api_key.token_id}) does not have permission') + self.redirect("/panel/error?error=Invalid Server ID") + return None + else: + if not self.controller.servers.server_id_authorized(server_id, exec_user["user_id"]): + print(f'User {exec_user["user_id"]} does not have permission') + self.redirect("/pandel/error?error=Invalid Server ID") + return None + return server_id + + # Server fetching, spawned asynchronously + # TODO: Make the related front-end elements update with AJAX + def fetch_server_data(self, page_data): + total_players = 0 + for server in page_data['servers']: + total_players += len(self.controller.stats.get_server_players(server['server_data']['server_id'])) + page_data['num_players'] = total_players + + for s in page_data['servers']: + try: + data = json.loads(s['int_ping_results']) + s['int_ping_results'] = data + except Exception as e: + logger.error(f"Failed server data for page with error: {e}") + + return page_data + + @tornado.web.authenticated - def get(self, page): - error = bleach.clean(self.get_argument('error', "WTF Error!")) + async def get(self, page): + error = self.get_argument('error', "WTF Error!") template = "panel/denied.html" now = time.time() formatted_time = str(datetime.datetime.fromtimestamp(now).strftime('%Y-%m-%d %H:%M:%S')) - exec_user_data = json.loads(self.get_secure_cookie("user_data")) - exec_user_id = exec_user_data['user_id'] - exec_user = self.controller.users.get_user_by_id(exec_user_id) + # pylint: disable=unused-variable + api_key, token_data, exec_user = self.current_user + superuser = exec_user['superuser'] + if api_key is not None: + superuser = superuser and api_key.superuser exec_user_role = set() - if exec_user['superuser'] == 1: + if superuser: # TODO: Figure out a better solution defined_servers = self.controller.list_defined_servers() exec_user_role.add("Super User") exec_user_crafty_permissions = self.controller.crafty_perms.list_defined_crafty_permissions() else: - exec_user_crafty_permissions = self.controller.crafty_perms.get_crafty_permissions_list(exec_user_id) + if api_key is not None: + exec_user_crafty_permissions = self.controller.crafty_perms.get_api_key_permissions_list(api_key) + else: + exec_user_crafty_permissions = self.controller.crafty_perms.get_crafty_permissions_list( + exec_user["user_id"]) logger.debug(exec_user['roles']) for r in exec_user['roles']: role = self.controller.roles.get_role(r) exec_user_role.add(role['role_name']) - defined_servers = self.controller.servers.get_authorized_servers(exec_user_id) + defined_servers = self.controller.servers.get_authorized_servers(exec_user["user_id"]) - page_data = { + + user_order = self.controller.users.get_user_by_id(exec_user['user_id']) + user_order = user_order['server_order'].split(',') + page_servers = [] + server_ids = [] + + for server_id in user_order[:]: + for server in defined_servers[:]: + if str(server['server_id']) == str(server_id): + page_servers.append(server) + user_order.remove(server_id) + defined_servers.remove(server) + + for server in defined_servers: + server_ids.append(str(server['server_id'])) + if server not in page_servers: + page_servers.append(server) + for server_id in user_order[:]: + #remove IDs in list that user no longer has access to + if str(server_id) not in server_ids: + user_order.remove(server_id) + defined_servers = page_servers + + + page_data: Dict[str, Any] = { # todo: make this actually pull and compare version data 'update_available': False, + 'serverTZ':get_localzone(), 'version_data': helper.get_version_string(), - 'user_data': exec_user_data, + 'user_data': exec_user, 'user_role' : exec_user_role, 'user_crafty_permissions' : exec_user_crafty_permissions, 'crafty_permissions': { @@ -73,10 +250,37 @@ class PanelHandler(BaseHandler): 'hosts_data': self.controller.management.get_latest_hosts_stats(), 'show_contribute': helper.get_setting("show_contribute_link", True), 'error': error, - 'time': formatted_time + 'time': formatted_time, + 'lang': self.controller.users.get_user_lang_by_id(exec_user["user_id"]), + 'lang_page': helper.getLangPage(self.controller.users.get_user_lang_by_id(exec_user["user_id"])), + 'super_user': superuser, + 'api_key': { + 'name': api_key.name, + 'created': api_key.created, + 'server_permissions': api_key.server_permissions, + 'crafty_permissions': api_key.crafty_permissions, + 'superuser': api_key.superuser + } if api_key is not None else None, + 'superuser': superuser } - page_data['lang'] = self.controller.users.get_user_lang_by_id(exec_user_id) - page_data['super_user'] = exec_user['superuser'] + if helper.get_setting("allow_nsfw_profile_pictures"): + rating = "x" + else: + rating = "g" + + + #Get grvatar hash for profile pictures + if exec_user['email'] != 'default@example.com' or "": + g = libgravatar.Gravatar(libgravatar.sanitize_email(exec_user['email'])) + url = g.get_image(size=80, default="404", force_default=False, rating=rating, filetype_extension=False, use_ssl=True) # + "?d=404" + if requests.head(url).status_code != 404: + profile_url = url + else: + profile_url = "/static/assets/images/faces-clipart/pic-3.png" + else: + profile_url = "/static/assets/images/faces-clipart/pic-3.png" + + page_data['user_image'] = profile_url if page == 'unauthorized': template = "panel/denied.html" @@ -85,111 +289,124 @@ class PanelHandler(BaseHandler): template = "public/error.html" elif page == 'credits': - with open(helper.credits_cache) as republic_credits_will_do: - credits = json.load(republic_credits_will_do) - timestamp = credits["lastUpdate"] / 1000.0 - page_data["patrons"] = credits["patrons"] - page_data["staff"] = credits["staff"] - page_data["translations"] = credits["translations"] - page_data["lastUpdate"] = str(datetime.datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S')) + with open(helper.credits_cache, encoding='utf-8') as credits_default_local: + try: + remote = requests.get('https://craftycontrol.com/credits', allow_redirects=True) + credits_dict: dict = remote.json() + if not credits_dict["staff"]: + logger.error("Issue with upstream Staff, using local.") + credits_dict: dict = json.load(credits_default_local) + except: + logger.error("Request to credits bucket failed, using local.") + credits_dict: dict = json.load(credits_default_local) + + timestamp = credits_dict["lastUpdate"] / 1000.0 + page_data["patrons"] = credits_dict["patrons"] + page_data["staff"] = credits_dict["staff"] + + # Filter Language keys to exclude joke prefix '*' + clean_dict = {user.replace('*', ''): translation for user, translation in credits_dict['translations'].items()} + page_data["translations"] = clean_dict + + # 0 Defines if we are using local credits file andd displays sadcat. + if timestamp == 0: + page_data["lastUpdate"] = '😿' + else: + page_data["lastUpdate"] = str(datetime.datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S')) template = "panel/credits.html" elif page == 'contribute': template = "panel/contribute.html" - elif page == "remove_server": - server_id = self.get_argument('id', None) - - if not exec_user['superuser']: - self.redirect("/panel/error?error=Unauthorized access: not superuser") - return - elif server_id is None: - self.redirect("/panel/error?error=Invalid Server ID") - return - - server_data = self.controller.get_server_data(server_id) - server_name = server_data['server_name'] - - self.controller.management.add_to_audit_log(exec_user_data['user_id'], - "Deleted server {} named {}".format(server_id, server_name), - server_id, - self.get_remote_ip()) - - self.controller.remove_server(server_id) - self.redirect("/panel/dashboard") - return - elif page == 'dashboard': - if exec_user['superuser'] == 1: - page_data['servers'] = self.controller.servers.get_all_servers_stats() - for data in page_data['servers']: - try: - data['stats']['waiting_start'] = self.controller.servers.get_waiting_start(int(data['stats']['server_id']['server_id'])) - except: - data['stats']['waiting_start'] = False - else: - user_auth = self.controller.servers.get_authorized_servers_stats(exec_user_id) - logger.debug("ASFR: {}".format(user_auth)) - page_data['servers'] = user_auth - page_data['server_stats']['running'] = 0 - page_data['server_stats']['stopped'] = 0 - for data in page_data['servers']: - if data['stats']['running']: - page_data['server_stats']['running'] += 1 - else: - page_data['server_stats']['stopped'] += 1 - try: - page_data['stats']['waiting_start'] = self.controller.servers.get_waiting_start(int(data['stats']['server_id']['server_id'])) - except: - data['stats']['waiting_start'] = False - - total_players = 0 - for server in page_data['servers']: - total_players += len(self.controller.stats.get_server_players(server['server_data']['server_id'])) - page_data['num_players'] = total_players - - for s in page_data['servers']: + if superuser: # TODO: Figure out a better solution try: - data = json.loads(s['int_ping_results']) - s['int_ping_results'] = data - except Exception as e: - logger.error("Failed server data for page with error: {} ".format(e)) + page_data['servers'] = self.controller.servers.get_all_servers_stats() + except IndexError: + self.controller.stats.record_stats() + page_data['servers'] = self.controller.servers.get_all_servers_stats() + else: + try: + user_auth = self.controller.servers.get_authorized_servers_stats(exec_user["user_id"]) + except IndexError: + self.controller.stats.record_stats() + user_auth = self.controller.servers.get_authorized_servers_stats(exec_user["user_id"]) + logger.debug(f"ASFR: {user_auth}") + page_data['servers'] = user_auth + page_data['server_stats']['running'] = len( + list(filter(lambda x: x['stats']['running'], page_data['servers']))) + page_data['server_stats']['stopped'] = len(page_data['servers']) - page_data['server_stats']['running'] + + #set user server order + user_order = self.controller.users.get_user_by_id(exec_user['user_id']) + user_order = user_order['server_order'].split(',') + page_servers = [] + server_ids = [] + un_used_servers = page_data['servers'] + flag = 0 + for server_id in user_order[:]: + for server in un_used_servers[:]: + if flag == 0: + server['stats']['downloading'] = self.controller.servers.get_download_status( + str(server['stats']['server_id']['server_id'])) + server['stats']['crashed'] = self.controller.servers.is_crashed( + str(server['stats']['server_id']['server_id'])) + try: + server['stats']['waiting_start'] = self.controller.servers.get_waiting_start( + str(server['stats']['server_id']['server_id'])) + except Exception as e: + logger.error(f"Failed to get server waiting to start: {e}") + server['stats']['waiting_start'] = False + + if str(server['server_data']['server_id']) == str(server_id): + page_servers.append(server) + un_used_servers.remove(server) + user_order.remove(server_id) + #we only want to set these server stats values once. We need to update the flag so it only hits that if once. + flag += 1 + + + + for server in un_used_servers: + server_ids.append(str(server['server_data']['server_id'])) + if server not in page_servers: + page_servers.append(server) + for server_id in user_order: + #remove IDs in list that user no longer has access to + if str(server_id) not in server_ids[:]: + user_order.remove(server_id) + page_data['servers'] = page_servers + + #num players is set to zero here. If we poll all servers while dashboard is loading it takes FOREVER. We leave this to the + #async polling once dashboard is served. + page_data['num_players'] = 0 template = "panel/dashboard.html" elif page == 'server_detail': - server_id = self.get_argument('id', None) subpage = bleach.clean(self.get_argument('subpage', "")) + server_id = self.check_server_id() if server_id is None: - self.redirect("/panel/error?error=Invalid Server ID") return - else: - # does this server id exist? - if not self.controller.servers.server_id_exists(server_id): - self.redirect("/panel/error?error=Invalid Server ID") - return - if exec_user['superuser'] != 1: - if not self.controller.servers.server_id_authorized(server_id, exec_user_id): - if not self.controller.servers.server_id_authorized(int(server_id), exec_user_id): - self.redirect("/panel/error?error=Invalid Server ID") - return False - - valid_subpages = ['term', 'logs', 'backup', 'config', 'files', 'admin_controls'] + valid_subpages = ['term', 'logs', 'backup', 'config', 'files', 'admin_controls', 'schedules'] if subpage not in valid_subpages: logger.debug('not a valid subpage') subpage = 'term' - logger.debug('Subpage: "{}"'.format(subpage)) + logger.debug(f'Subpage: "{subpage}"') server = self.controller.get_server_obj(server_id) # server_data isn't needed since the server_stats also pulls server data page_data['server_data'] = self.controller.servers.get_server_data_by_id(server_id) page_data['server_stats'] = self.controller.servers.get_server_stats_by_id(server_id) + page_data['downloading'] = self.controller.servers.get_download_status( + server_id) try: page_data['waiting_start'] = self.controller.servers.get_waiting_start(server_id) - except: + except Exception as e: + logger.error(f"Failed to get server waiting to start: {e}") page_data['waiting_start'] = False page_data['get_players'] = lambda: self.controller.stats.get_server_players(server_id) page_data['active_link'] = subpage @@ -203,13 +420,65 @@ class PanelHandler(BaseHandler): 'Config': Enum_Permissions_Server.Config, 'Players': Enum_Permissions_Server.Players, } - page_data['user_permissions'] = self.controller.server_perms.get_server_permissions_foruser(exec_user_id, server_id) + page_data['user_permissions'] = self.controller.server_perms.get_user_id_permissions_list(exec_user["user_id"], server_id) + page_data['server_stats']['crashed'] = self.controller.servers.is_crashed(server_id) + page_data['server_stats']['server_type'] = self.controller.servers.get_server_type_by_id(server_id) + + if subpage == 'term': + if not page_data['permissions']['Terminal'] in page_data['user_permissions']: + if not superuser: + self.redirect("/panel/error?error=Unauthorized access to Terminal") + return + + if subpage == 'logs': + if not page_data['permissions']['Logs'] in page_data['user_permissions']: + if not superuser: + self.redirect("/panel/error?error=Unauthorized access to Logs") + return + + + if subpage == 'schedules': + if not page_data['permissions']['Schedule'] in page_data['user_permissions']: + if not superuser: + self.redirect("/panel/error?error=Unauthorized access To Schedules") + return + page_data['schedules'] = management_helper.get_schedules_by_server(server_id) + + if subpage == 'config': + if not page_data['permissions']['Config'] in page_data['user_permissions']: + if not superuser: + self.redirect("/panel/error?error=Unauthorized access Server Config") + return + + if subpage == 'files': + if not page_data['permissions']['Files'] in page_data['user_permissions']: + if not superuser: + self.redirect("/panel/error?error=Unauthorized access Files") + return + if subpage == "backup": + if not page_data['permissions']['Backup'] in page_data['user_permissions']: + if not superuser: + self.redirect("/panel/error?error=Unauthorized access to Backups") + return server_info = self.controller.servers.get_server_data_by_id(server_id) page_data['backup_config'] = self.controller.management.get_backup_config(server_id) - page_data['backup_list'] = server.list_backups() - page_data['backup_path'] = server_info["backup_path"].replace('\\', '/') + exclusions = [] + page_data['exclusions'] = self.controller.management.get_excluded_backup_dirs(server_id) + #makes it so relative path is the only thing shown + for file in page_data['exclusions']: + if helper.is_os_windows(): + exclusions.append(file.replace(server_info['path']+'\\', "")) + else: + exclusions.append(file.replace(server_info['path']+'/', "")) + page_data['exclusions'] = exclusions + self.controller.refresh_server_settings(server_id) + try: + page_data['backup_list'] = server.list_backups() + except: + page_data['backup_list'] = [] + page_data['backup_path'] = helper.wtol_path(server_info["backup_path"]) def get_banned_players_html(): banned_players = self.controller.servers.get_banned_players(server_id) @@ -221,91 +490,55 @@ class PanelHandler(BaseHandler): """ html = "" for player in banned_players: - html += """ + html += f"""
          • -

            {}

            - Banned by {} for reason: {} - +

            {player['name']}

            + Banned by {player['source']} for reason: {player['reason']} +
          • - """.format(player['name'], player['source'], player['reason'], player['name']) + """ return html if subpage == "admin_controls": + if not page_data['permissions']['Players'] in page_data['user_permissions']: + if not superuser: + self.redirect("/panel/error?error=Unauthorized access") page_data['banned_players'] = get_banned_players_html() - # template = "panel/server_details.html" - template = "panel/server_{subpage}.html".format(subpage=subpage) + template = f"panel/server_{subpage}.html" elif page == 'download_backup': - server_id = self.get_argument('id', None) file = self.get_argument('file', "") + server_id = self.check_server_id() if server_id is None: - self.redirect("/panel/error?error=Invalid Server ID") return - else: - # does this server id exist? - if not self.controller.servers.server_id_exists(server_id): - self.redirect("/panel/error?error=Invalid Server ID") - return - if exec_user['superuser'] != 1: - #if not self.controller.servers.server_id_authorized(server_id, exec_user_id): - if not self.controller.servers.server_id_authorized(int(server_id), exec_user_id): - self.redirect("/panel/error?error=Invalid Server ID") - return server_info = self.controller.servers.get_server_data_by_id(server_id) - backup_file = os.path.abspath(os.path.join(server_info["backup_path"], file)) - if not helper.in_path(server_info["backup_path"], backup_file) \ + backup_file = os.path.abspath(os.path.join(helper.get_os_understandable_path(server_info["backup_path"]), file)) + if not helper.in_path(helper.get_os_understandable_path(server_info["backup_path"]), backup_file) \ or not os.path.isfile(backup_file): self.redirect("/panel/error?error=Invalid path detected") return - self.set_header('Content-Type', 'application/octet-stream') - self.set_header('Content-Disposition', 'attachment; filename=' + file) - chunk_size = 1024 * 1024 * 4 # 4 MiB + self.download_file(file, backup_file) - with open(backup_file, 'rb') as f: - while True: - chunk = f.read(chunk_size) - if not chunk: - break - try: - self.write(chunk) # write the chunk to response - self.flush() # send the chunk to client - except iostream.StreamClosedError: - # this means the client has closed the connection - # so break the loop - break - finally: - # deleting the chunk is very important because - # if many clients are downloading files at the - # same time, the chunks in memory will keep - # increasing and will eat up the RAM - del chunk - self.redirect("/panel/server_detail?id={}&subpage=backup".format(server_id)) + self.redirect(f"/panel/server_detail?id={server_id}&subpage=backup") elif page == 'backup_now': - server_id = self.get_argument('id', None) - + server_id = self.check_server_id() if server_id is None: - self.redirect("/panel/error?error=Invalid Server ID") return - else: - # does this server id exist? - if not self.controller.servers.server_id_exists(server_id): - self.redirect("/panel/error?error=Invalid Server ID") - return - if exec_user['superuser'] != 1: - #if not self.controller.servers.server_id_authorized(server_id, exec_user_id): - if not self.controller.servers.server_id_authorized(int(server_id), exec_user_id): - self.redirect("/panel/error?error=Invalid Server ID") - return + server = self.controller.get_server_obj(server_id) + management_helper.add_to_audit_log_raw( + self.controller.users.get_user_by_id(exec_user['user_id'])['username'], exec_user['user_id'], server_id, + f"Backup now executed for server {server_id} ", + source_ip=self.get_remote_ip()) - server = self.controller.get_server_obj(server_id).backup_server() - self.redirect("/panel/server_detail?id={}&subpage=backup".format(server_id)) + server.backup_server() + self.redirect(f"/panel/server_detail?id={server_id}&subpage=backup") elif page == 'panel_config': auth_servers = {} @@ -338,19 +571,21 @@ class PanelHandler(BaseHandler): page_data['role-servers'] = auth_role_servers page_data['user-roles'] = user_roles - if exec_user['superuser'] == 1: - super_auth_servers = [] - super_auth_servers.append("Access To All Servers") - page_data['users'] = self.controller.users.get_all_users() - page_data['roles'] = self.controller.roles.get_all_roles() - page_data['auth-servers'][exec_user['user_id']] = super_auth_servers - else: - page_data['users'] = self.controller.users.user_query(exec_user['user_id']) - page_data['roles'] = self.controller.users.user_role_query(exec_user['user_id']) + page_data['users'] = self.controller.users.user_query(exec_user['user_id']) + page_data['roles'] = self.controller.users.user_role_query(exec_user['user_id']) + for user in page_data['users']: if user.user_id != exec_user['user_id']: user.api_token = "********" + if superuser: + for user in self.controller.users.get_all_users(): + if user.superuser: + super_auth_servers = ["Super User Access To All Servers"] + page_data['users'] = self.controller.users.get_all_users() + page_data['roles'] = self.controller.roles.get_all_roles() + page_data['auth-servers'][user.user_id] = super_auth_servers + template = "panel/panel_config.html" elif page == "add_user": @@ -358,9 +593,9 @@ class PanelHandler(BaseHandler): page_data['user'] = {} page_data['user']['username'] = "" page_data['user']['user_id'] = -1 + page_data['user']['email'] = "" page_data['user']['enabled'] = True page_data['user']['superuser'] = False - page_data['user']['api_token'] = "N/A" page_data['user']['created'] = "N/A" page_data['user']['last_login'] = "N/A" page_data['user']['last_ip'] = "N/A" @@ -379,14 +614,118 @@ class PanelHandler(BaseHandler): page_data['permissions_list'] = set() page_data['quantity_server'] = self.controller.crafty_perms.list_all_crafty_permissions_quantity_limits() page_data['languages'] = [] - page_data['languages'].append(self.controller.users.get_user_lang_by_id(exec_user_id)) - for file in os.listdir(os.path.join(helper.root_dir, 'app', 'translations')): + page_data['languages'].append(self.controller.users.get_user_lang_by_id(exec_user["user_id"])) + if superuser: + page_data['super-disabled'] = '' + else: + page_data['super-disabled'] = 'disabled' + for file in sorted(os.listdir(os.path.join(helper.root_dir, 'app', 'translations'))): if file.endswith('.json'): - if file != str(page_data['languages'][0] + '.json'): - page_data['languages'].append(file.split('.')[0]) + if file not in helper.get_setting('disabled_language_files'): + if file != str(page_data['languages'][0] + '.json'): + page_data['languages'].append(file.split('.')[0]) template = "panel/panel_edit_user.html" + elif page == "add_schedule": + server_id = self.get_argument('id', None) + page_data['schedules'] = management_helper.get_schedules_by_server(server_id) + page_data['get_players'] = lambda: self.controller.stats.get_server_players(server_id) + page_data['active_link'] = 'schedules' + page_data['permissions'] = { + 'Commands': Enum_Permissions_Server.Commands, + 'Terminal': Enum_Permissions_Server.Terminal, + 'Logs': Enum_Permissions_Server.Logs, + 'Schedule': Enum_Permissions_Server.Schedule, + 'Backup': Enum_Permissions_Server.Backup, + 'Files': Enum_Permissions_Server.Files, + 'Config': Enum_Permissions_Server.Config, + 'Players': Enum_Permissions_Server.Players, + } + page_data['user_permissions'] = self.controller.server_perms.get_user_id_permissions_list(exec_user["user_id"], server_id) + page_data['server_data'] = self.controller.servers.get_server_data_by_id(server_id) + page_data['server_stats'] = self.controller.servers.get_server_stats_by_id(server_id) + page_data['server_stats']['server_type'] = self.controller.servers.get_server_type_by_id(server_id) + page_data['new_schedule'] = True + page_data['schedule'] = {} + page_data['schedule']['children'] = [] + page_data['schedule']['server_id'] = server_id + page_data['schedule']['schedule_id'] = '' + page_data['schedule']['action'] = "" + page_data['schedule']['enabled'] = True + page_data['schedule']['command'] = '' + page_data['schedule']['one_time'] = False + page_data['schedule']['cron_string'] = "" + page_data['schedule']['time'] = "" + page_data['schedule']['interval'] = "" + #we don't need to check difficulty here. We'll just default to basic for new schedules + page_data['schedule']['difficulty'] = "basic" + page_data['schedule']['interval_type'] = 'days' + + if not Enum_Permissions_Server.Schedule in page_data['user_permissions']: + if not superuser: + self.redirect("/panel/error?error=Unauthorized access To Schedules") + return + + template = "panel/server_schedule_edit.html" + + elif page == "edit_schedule": + server_id = self.get_argument('id', None) + page_data['schedules'] = management_helper.get_schedules_by_server(server_id) + sch_id = self.get_argument('sch_id', None) + schedule = self.controller.management.get_scheduled_task_model(sch_id) + page_data['get_players'] = lambda: self.controller.stats.get_server_players(server_id) + page_data['active_link'] = 'schedules' + page_data['permissions'] = { + 'Commands': Enum_Permissions_Server.Commands, + 'Terminal': Enum_Permissions_Server.Terminal, + 'Logs': Enum_Permissions_Server.Logs, + 'Schedule': Enum_Permissions_Server.Schedule, + 'Backup': Enum_Permissions_Server.Backup, + 'Files': Enum_Permissions_Server.Files, + 'Config': Enum_Permissions_Server.Config, + 'Players': Enum_Permissions_Server.Players, + } + page_data['user_permissions'] = self.controller.server_perms.get_user_id_permissions_list(exec_user["user_id"], server_id) + page_data['server_data'] = self.controller.servers.get_server_data_by_id(server_id) + page_data['server_stats'] = self.controller.servers.get_server_stats_by_id(server_id) + page_data['server_stats']['server_type'] = self.controller.servers.get_server_type_by_id(server_id) + page_data['new_schedule'] = False + page_data['schedule'] = {} + page_data['schedule']['server_id'] = server_id + page_data['schedule']['schedule_id'] = schedule.schedule_id + page_data['schedule']['action'] = schedule.action + page_data['schedule']['children'] = self.controller.management.get_child_schedules(sch_id) + # We check here to see if the command is any of the default ones. + # We do not want a user changing to a custom command and seeing our command there. + if schedule.action != 'start' or schedule.action != 'stop' or schedule.action != 'restart' or schedule.action != 'backup': + page_data['schedule']['command'] = schedule.command + else: + page_data['schedule']['command'] = '' + page_data['schedule']['enabled'] = schedule.enabled + page_data['schedule']['one_time'] = schedule.one_time + page_data['schedule']['cron_string'] = schedule.cron_string + page_data['schedule']['time'] = schedule.start_time + page_data['schedule']['interval'] = schedule.interval + page_data['schedule']['interval_type'] = schedule.interval_type + if schedule.interval_type == 'reaction': + difficulty = 'reaction' + elif schedule.cron_string == '': + difficulty = 'basic' + else: + difficulty = 'advanced' + page_data['schedule']['difficulty'] = difficulty + + if sch_id is None or server_id is None: + self.redirect("/panel/error?error=Invalid server ID or Schedule ID") + + if not Enum_Permissions_Server.Schedule in page_data['user_permissions']: + if not superuser: + self.redirect("/panel/error?error=Unauthorized access To Schedules") + return + + template = "panel/server_schedule_edit.html" + elif page == "edit_user": user_id = self.get_argument('id', None) role_servers = self.controller.servers.get_authorized_servers(user_id) @@ -405,18 +744,23 @@ class PanelHandler(BaseHandler): page_data['quantity_server'] = self.controller.crafty_perms.list_crafty_permissions_quantity_limits(user_id) page_data['languages'] = [] page_data['languages'].append(self.controller.users.get_user_lang_by_id(user_id)) - for file in os.listdir(os.path.join(helper.root_dir, 'app', 'translations')): + #checks if super user. If not we disable the button. + if superuser and str(exec_user['user_id']) != str(user_id): + page_data['super-disabled'] = '' + else: + page_data['super-disabled'] = 'disabled' + + for file in sorted(os.listdir(os.path.join(helper.root_dir, 'app', 'translations'))): if file.endswith('.json'): - if file != str(page_data['languages'][0] + '.json'): - page_data['languages'].append(file.split('.')[0]) + if file not in helper.get_setting('disabled_language_files'): + if file != str(page_data['languages'][0] + '.json'): + page_data['languages'].append(file.split('.')[0]) if user_id is None: self.redirect("/panel/error?error=Invalid User ID") return elif Enum_Permissions_Crafty.User_Config not in exec_user_crafty_permissions: - if str(user_id) != str(exec_user_id): - print("USER ID ", user_id) - print("EXEC ID ", exec_user_id) + if str(user_id) != str(exec_user["user_id"]): self.redirect("/panel/error?error=Unauthorized access: not a user editor") return @@ -427,14 +771,35 @@ class PanelHandler(BaseHandler): if exec_user['user_id'] != page_data['user']['user_id']: page_data['user']['api_token'] = "********" + + if exec_user['email'] == 'default@example.com': + page_data['user']['email'] = "" template = "panel/panel_edit_user.html" + elif page == "edit_user_apikeys": + user_id = self.get_argument('id', None) + page_data['user'] = self.controller.users.get_user_by_id(user_id) + page_data['api_keys'] = self.controller.users.get_user_api_keys(user_id) + # self.controller.crafty_perms.list_defined_crafty_permissions() + page_data['server_permissions_all'] = self.controller.server_perms.list_defined_permissions() + page_data['crafty_permissions_all'] = self.controller.crafty_perms.list_defined_crafty_permissions() + + if user_id is None: + self.redirect("/panel/error?error=Invalid User ID") + return + + template = "panel/panel_edit_user_apikeys.html" + elif page == "remove_user": user_id = bleach.clean(self.get_argument('id', None)) - if not exec_user['superuser']: + if not superuser and Enum_Permissions_Crafty.User_Config not in exec_user_crafty_permissions: self.redirect("/panel/error?error=Unauthorized access: not superuser") return + + elif str(exec_user["user_id"]) == str(user_id): + self.redirect("/panel/error?error=Unauthorized access: you cannot delete yourself") + return elif user_id is None: self.redirect("/panel/error?error=Invalid User ID") return @@ -451,18 +816,13 @@ class PanelHandler(BaseHandler): self.controller.users.remove_user(user_id) self.controller.management.add_to_audit_log(exec_user['user_id'], - "Removed user {} (UID:{})".format(target_user['username'], user_id), + f"Removed user {target_user['username']} (UID:{user_id})", server_id=0, source_ip=self.get_remote_ip()) self.redirect("/panel/panel_config") elif page == "add_role": - user_roles = {} - for user in self.controller.users.get_all_users(): - user_roles_list = self.controller.users.get_user_roles_names(user.user_id) - user_servers = self.controller.servers.get_authorized_servers(user.user_id) - data = {user.user_id: user_roles_list} - user_roles.update(data) + user_roles = self.get_user_roles() page_data['new_role'] = True page_data['role'] = {} page_data['role']['role_name'] = "" @@ -483,14 +843,7 @@ class PanelHandler(BaseHandler): template = "panel/panel_edit_role.html" elif page == "edit_role": - auth_servers = {} - - user_roles = {} - for user in self.controller.users.get_all_users(): - user_roles_list = self.controller.users.get_user_roles_names(user.user_id) - user_servers = self.controller.servers.get_authorized_servers(user.user_id) - data = {user.user_id: user_roles_list} - user_roles.update(data) + user_roles = self.get_user_roles() page_data['new_role'] = False role_id = self.get_argument('id', None) page_data['role'] = self.controller.roles.get_role_with_servers(role_id) @@ -512,7 +865,7 @@ class PanelHandler(BaseHandler): elif page == "remove_role": role_id = bleach.clean(self.get_argument('id', None)) - if not exec_user['superuser']: + if not superuser: self.redirect("/panel/error?error=Unauthorized access: not superuser") return elif role_id is None: @@ -528,7 +881,7 @@ class PanelHandler(BaseHandler): self.controller.roles.remove_role(role_id) self.controller.management.add_to_audit_log(exec_user['user_id'], - "Removed role {} (RID:{})".format(target_role['role_name'], role_id), + f"Removed role {target_role['role_name']} (RID:{role_id})", server_id=0, source_ip=self.get_remote_ip()) self.redirect("/panel/panel_config") @@ -539,54 +892,65 @@ class PanelHandler(BaseHandler): template = "panel/activity_logs.html" elif page == 'download_file': - server_id = self.get_argument('id', None) - file = self.get_argument('path', "") + file = helper.get_os_understandable_path(self.get_argument('path', "")) name = self.get_argument('name', "") + server_id = self.check_server_id() if server_id is None: - self.redirect("/panel/error?error=Invalid Server ID") return - else: - # does this server id exist? - if not self.controller.servers.server_id_exists(server_id): - self.redirect("/panel/error?error=Invalid Server ID") - return - - if exec_user['superuser'] != 1: - if not self.controller.servers.server_id_authorized(int(server_id), exec_user_id): - self.redirect("/panel/error?error=Invalid Server ID") - return server_info = self.controller.servers.get_server_data_by_id(server_id) - if not helper.in_path(server_info["path"], file) \ + if not helper.in_path(helper.get_os_understandable_path(server_info["path"]), file) \ or not os.path.isfile(file): self.redirect("/panel/error?error=Invalid path detected") return - self.set_header('Content-Type', 'application/octet-stream') - self.set_header('Content-Disposition', 'attachment; filename=' + name) - chunk_size = 1024 * 1024 * 4 # 4 MiB + self.download_file(name, file) + self.redirect(f"/panel/server_detail?id={server_id}&subpage=files") + + elif page == 'download_support_package': + tempZipStorage = exec_user['support_logs'] + #We'll reset the support path for this user now. + self.controller.users.set_support_path(exec_user["user_id"], "") + + self.set_header('Content-Type', 'application/octet-stream') + self.set_header('Content-Disposition', 'attachment; filename=' + "support_logs.zip") + chunk_size = 1024 * 1024 * 4 # 4 MiB + if tempZipStorage != '': + with open(tempZipStorage, 'rb') as f: + while True: + chunk = f.read(chunk_size) + if not chunk: + break + try: + self.write(chunk) # write the chunk to response + self.flush() # send the chunk to client + except iostream.StreamClosedError: + # this means the client has closed the connection + # so break the loop + break + finally: + # deleting the chunk is very important because + # if many clients are downloading files at the + # same time, the chunks in memory will keep + # increasing and will eat up the RAM + del chunk + self.redirect('/panel/dashboard') + else: + self.redirect('/panel/error?error=No path found for support logs') + return + + elif page == "support_logs": + logger.info(f"Support logs requested. Packinging logs for user with ID: {exec_user['user_id']}") + logs_thread = threading.Thread(target=self.controller.package_support_logs, + daemon=True, + args=(exec_user,), + name=f"{exec_user['user_id']}_logs_thread") + logs_thread.start() + self.redirect('/panel/dashboard') + return - with open(file, 'rb') as f: - while True: - chunk = f.read(chunk_size) - if not chunk: - break - try: - self.write(chunk) # write the chunk to response - self.flush() # send the chunk to client - except iostream.StreamClosedError: - # this means the client has closed the connection - # so break the loop - break - finally: - # deleting the chunk is very important because - # if many clients are downloading files at the - # same time, the chunks in memory will keep - # increasing and will eat up the RAM - del chunk - self.redirect("/panel/server_detail?id={}&subpage=files".format(server_id)) self.render( @@ -599,90 +963,135 @@ class PanelHandler(BaseHandler): @tornado.web.authenticated def post(self, page): - exec_user_data = json.loads(self.get_secure_cookie("user_data")) - exec_user_id = exec_user_data['user_id'] - exec_user = self.controller.users.get_user_by_id(exec_user_id) + # pylint: disable=unused-variable + api_key, token_data, exec_user = self.current_user + superuser = exec_user['superuser'] + if api_key is not None: + superuser = superuser and api_key.superuser + server_id = self.get_argument('id', None) + permissions = { + 'Commands': Enum_Permissions_Server.Commands, + 'Terminal': Enum_Permissions_Server.Terminal, + 'Logs': Enum_Permissions_Server.Logs, + 'Schedule': Enum_Permissions_Server.Schedule, + 'Backup': Enum_Permissions_Server.Backup, + 'Files': Enum_Permissions_Server.Files, + 'Config': Enum_Permissions_Server.Config, + 'Players': Enum_Permissions_Server.Players, + } exec_user_role = set() - if exec_user['superuser'] == 1: - defined_servers = self.controller.list_defined_servers() + if superuser: + # defined_servers = self.controller.list_defined_servers() exec_user_role.add("Super User") exec_user_crafty_permissions = self.controller.crafty_perms.list_defined_crafty_permissions() else: - exec_user_crafty_permissions = self.controller.crafty_perms.get_crafty_permissions_list(exec_user_id) - defined_servers = self.controller.servers.get_authorized_servers(exec_user_id) + exec_user_crafty_permissions = self.controller.crafty_perms.get_crafty_permissions_list(exec_user["user_id"]) + # defined_servers = self.controller.servers.get_authorized_servers(exec_user["user_id"]) for r in exec_user['roles']: role = self.controller.roles.get_role(r) exec_user_role.add(role['role_name']) if page == 'server_detail': - server_id = self.get_argument('id', None) + if not permissions['Config'] in self.controller.server_perms.get_user_id_permissions_list(exec_user["user_id"], server_id): + if not superuser: + self.redirect("/panel/error?error=Unauthorized access to Config") + return server_name = self.get_argument('server_name', None) - server_path = self.get_argument('server_path', None) - log_path = self.get_argument('log_path', None) - executable = self.get_argument('executable', None) - execution_command = self.get_argument('execution_command', None) + server_obj = self.controller.servers.get_server_obj(server_id) + if superuser: + server_path = self.get_argument('server_path', None) + if helper.is_os_windows(): + server_path.replace(' ', '^ ') + server_path = helper.wtol_path(server_path) + log_path = self.get_argument('log_path', None) + if helper.is_os_windows(): + log_path.replace(' ', '^ ') + log_path = helper.wtol_path(log_path) + executable = self.get_argument('executable', None) + execution_command = self.get_argument('execution_command', None) + server_ip = self.get_argument('server_ip', None) + server_port = self.get_argument('server_port', None) + executable_update_url = self.get_argument('executable_update_url', None) + else: + execution_command = server_obj.execution_command + executable = server_obj.executable stop_command = self.get_argument('stop_command', None) auto_start_delay = self.get_argument('auto_start_delay', '10') - server_ip = self.get_argument('server_ip', None) - server_port = self.get_argument('server_port', None) - executable_update_url = self.get_argument('executable_update_url', None) auto_start = int(float(self.get_argument('auto_start', '0'))) crash_detection = int(float(self.get_argument('crash_detection', '0'))) logs_delete_after = int(float(self.get_argument('logs_delete_after', '0'))) - subpage = self.get_argument('subpage', None) + # subpage = self.get_argument('subpage', None) - if not exec_user['superuser']: - if not self.controller.servers.server_id_authorized(server_id, exec_user_id): - self.redirect("/panel/error?error=Unauthorized access: invalid server id") - return - elif server_id is None: - self.redirect("/panel/error?error=Invalid Server ID") + server_id = self.check_server_id() + if server_id is None: return - else: - # does this server id exist? - if not self.controller.servers.server_id_exists(server_id): - self.redirect("/panel/error?error=Invalid Server ID") - return - #TODO use controller method - Servers.update({ - Servers.server_name: server_name, - Servers.path: server_path, - Servers.log_path: log_path, - Servers.executable: executable, - Servers.execution_command: execution_command, - Servers.stop_command: stop_command, - Servers.auto_start_delay: auto_start_delay, - Servers.server_ip: server_ip, - Servers.server_port: server_port, - Servers.auto_start: auto_start, - Servers.executable_update_url: executable_update_url, - Servers.crash_detection: crash_detection, - Servers.logs_delete_after: logs_delete_after, - }).where(Servers.server_id == server_id).execute() + server_obj = self.controller.servers.get_server_obj(server_id) + stale_executable = server_obj.executable + #Compares old jar name to page data being passed. If they are different we replace the executable name in the + if str(stale_executable) != str(executable): + execution_command = execution_command.replace(str(stale_executable), str(executable)) + + server_obj.server_name = server_name + if superuser: + if helper.validate_traversal(helper.get_servers_root_dir(), server_path): + server_obj.path = server_path + server_obj.log_path = log_path + if helper.validate_traversal(helper.get_servers_root_dir(), executable): + server_obj.executable = executable + server_obj.execution_command = execution_command + server_obj.server_ip = server_ip + server_obj.server_port = server_port + server_obj.executable_update_url = executable_update_url + else: + server_obj.path = server_obj.path + server_obj.log_path = server_obj.log_path + server_obj.executable = server_obj.executable + print(server_obj.execution_command) + server_obj.execution_command = server_obj.execution_command + server_obj.server_ip = server_obj.server_ip + server_obj.server_port = server_obj.server_port + server_obj.executable_update_url = server_obj.executable_update_url + server_obj.stop_command = stop_command + server_obj.auto_start_delay = auto_start_delay + server_obj.auto_start = auto_start + server_obj.crash_detection = crash_detection + server_obj.logs_delete_after = logs_delete_after + self.controller.servers.update_server(server_obj) + self.controller.crash_detection(server_obj) self.controller.refresh_server_settings(server_id) self.controller.management.add_to_audit_log(exec_user['user_id'], - "Edited server {} named {}".format(server_id, server_name), + f"Edited server {server_id} named {server_name}", server_id, self.get_remote_ip()) - self.redirect("/panel/server_detail?id={}&subpage=config".format(server_id)) + self.redirect(f"/panel/server_detail?id={server_id}&subpage=config") if page == "server_backup": logger.debug(self.request.arguments) server_id = self.get_argument('id', None) - backup_path = bleach.clean(self.get_argument('backup_path', None)) + server_obj = self.controller.servers.get_server_obj(server_id) + compress = self.get_argument('compress', False) + check_changed = self.get_argument('changed') + if str(check_changed) == str(1): + checked = self.get_body_arguments('root_path') + else: + checked = self.controller.management.get_excluded_backup_dirs(server_id) + if superuser: + backup_path = bleach.clean(self.get_argument('backup_path', None)) + if helper.is_os_windows(): + backup_path.replace(' ', '^ ') + backup_path = helper.wtol_path(backup_path) + else: + backup_path = server_obj.backup_path max_backups = bleach.clean(self.get_argument('max_backups', None)) - try: - enabled = int(float(bleach.clean(self.get_argument('auto_enabled'), '0'))) - except Exception as e: - enabled = '0' - if not exec_user['superuser']: - self.redirect("/panel/error?error=Unauthorized access: not superuser") + if not permissions['Backup'] in self.controller.server_perms.get_user_id_permissions_list(exec_user["user_id"], server_id): + if not superuser: + self.redirect("/panel/error?error=Unauthorized access: User not authorized") return elif server_id is None: self.redirect("/panel/error?error=Invalid Server ID") @@ -693,36 +1102,346 @@ class PanelHandler(BaseHandler): self.redirect("/panel/error?error=Invalid Server ID") return - if backup_path is not None: - if enabled == '0': - Servers.update({ - Servers.backup_path: backup_path - }).where(Servers.server_id == server_id).execute() - self.controller.management.set_backup_config(server_id, max_backups=max_backups, auto_enabled=False) - else: - Servers.update({ - Servers.backup_path: backup_path - }).where(Servers.server_id == server_id).execute() - self.controller.management.set_backup_config(server_id, max_backups=max_backups, auto_enabled=True) + server_obj = self.controller.servers.get_server_obj(server_id) + server_obj.backup_path = backup_path + self.controller.servers.update_server(server_obj) + self.controller.management.set_backup_config(server_id, max_backups=max_backups, excluded_dirs=checked, compress=bool(compress)) self.controller.management.add_to_audit_log(exec_user['user_id'], - "Edited server {}: updated backups".format(server_id), + f"Edited server {server_id}: updated backups", server_id, self.get_remote_ip()) self.tasks_manager.reload_schedule_from_db() - self.redirect("/panel/server_detail?id={}&subpage=backup".format(server_id)) + self.redirect(f"/panel/server_detail?id={server_id}&subpage=backup") + + + if page == "new_schedule": + server_id = bleach.clean(self.get_argument('id', None)) + difficulty = bleach.clean(self.get_argument('difficulty', None)) + server_obj = self.controller.servers.get_server_obj(server_id) + enabled = bleach.clean(self.get_argument('enabled', '0')) + if difficulty == 'basic': + action = bleach.clean(self.get_argument('action', None)) + interval = bleach.clean(self.get_argument('interval', None)) + interval_type = bleach.clean(self.get_argument('interval_type', None)) + #only check for time if it's number of days + if interval_type == "days": + sch_time = bleach.clean(self.get_argument('time', None)) + if action == "command": + command = bleach.clean(self.get_argument('command', None)) + elif action == "start": + command = "start_server" + elif action == "stop": + command = "stop_server" + elif action == "restart": + command = "restart_server" + elif action == "backup": + command = "backup_server" + + elif difficulty == 'reaction': + interval_type = 'reaction' + action = bleach.clean(self.get_argument('action', None)) + delay = bleach.clean(self.get_argument('delay', None)) + parent = bleach.clean(self.get_argument('parent', None)) + if action == "command": + command = bleach.clean(self.get_argument('command', None)) + elif action == "start": + command = "start_server" + elif action == "stop": + command = "stop_server" + elif action == "restart": + command = "restart_server" + elif action == "backup": + command = "backup_server" + + else: + interval_type = '' + cron_string = bleach.clean(self.get_argument('cron', '')) + try: + CronValidator.parse(cron_string) + except Exception as e: + self.redirect(f"/panel/error?error=INVALID FORMAT: Invalid Cron Format. {e}") + return + action = bleach.clean(self.get_argument('action', None)) + if action == "command": + command = bleach.clean(self.get_argument('command', None)) + elif action == "start": + command = "start_server" + elif action == "stop": + command = "stop_server" + elif action == "restart": + command = "restart_server" + elif action == "backup": + command = "backup_server" + if bleach.clean(self.get_argument('enabled', '0')) == '1': + enabled = True + else: + enabled = False + if bleach.clean(self.get_argument('one_time', '0')) == '1': + one_time = True + else: + one_time = False + + if not superuser and not permissions['Backup'] in self.controller.server_perms.get_user_id_permissions_list(exec_user["user_id"], + server_id): + self.redirect("/panel/error?error=Unauthorized access: User not authorized") + return + elif server_id is None: + self.redirect("/panel/error?error=Invalid Server ID") + return + else: + # does this server id exist? + if not self.controller.servers.server_id_exists(server_id): + self.redirect("/panel/error?error=Invalid Server ID") + return + + if interval_type == "days": + job_data = { + "server_id": server_id, + "action": action, + "interval_type": interval_type, + "interval": interval, + "command": command, + "start_time": sch_time, + "enabled": enabled, + "one_time": one_time, + "cron_string": '', + "parent": None, + "delay": 0 + } + elif difficulty == "reaction": + job_data = { + "server_id": server_id, + "action": action, + "interval_type": interval_type, + "interval": '', + #We'll base every interval off of a midnight start time. + "start_time": '', + "command": command, + "cron_string": '', + "enabled": enabled, + "one_time": one_time, + "parent": parent, + "delay": delay + } + elif difficulty == "advanced": + job_data = { + "server_id": server_id, + "action": action, + "interval_type": '', + "interval": '', + #We'll base every interval off of a midnight start time. + "start_time": '', + "command": command, + "cron_string": cron_string, + "enabled": enabled, + "one_time": one_time, + "parent": None, + "delay": 0 + } + else: + job_data = { + "server_id": server_id, + "action": action, + "interval_type": interval_type, + "interval": interval, + "command": command, + "enabled": enabled, + #We'll base every interval off of a midnight start time. + "start_time": '00:00', + "one_time": one_time, + "cron_string": '', + 'parent': None, + 'delay': 0 + } + + self.tasks_manager.schedule_job(job_data) + + self.controller.management.add_to_audit_log(exec_user['user_id'], + f"Edited server {server_id}: added scheduled job", + server_id, + self.get_remote_ip()) + self.tasks_manager.reload_schedule_from_db() + self.redirect(f"/panel/server_detail?id={server_id}&subpage=schedules") + + + if page == "edit_schedule": + server_id = bleach.clean(self.get_argument('id', None)) + difficulty = bleach.clean(self.get_argument('difficulty', None)) + server_obj = self.controller.servers.get_server_obj(server_id) + enabled = bleach.clean(self.get_argument('enabled', '0')) + if difficulty == 'basic': + action = bleach.clean(self.get_argument('action', None)) + interval = bleach.clean(self.get_argument('interval', None)) + interval_type = bleach.clean(self.get_argument('interval_type', None)) + #only check for time if it's number of days + if interval_type == "days": + sch_time = bleach.clean(self.get_argument('time', None)) + if action == "command": + command = bleach.clean(self.get_argument('command', None)) + elif action == "start": + command = "start_server" + elif action == "stop": + command = "stop_server" + elif action == "restart": + command = "restart_server" + elif action == "backup": + command = "backup_server" + elif difficulty == 'reaction': + interval_type = 'reaction' + action = bleach.clean(self.get_argument('action', None)) + delay = bleach.clean(self.get_argument('delay', None)) + parent = bleach.clean(self.get_argument('parent', None)) + if action == "command": + command = bleach.clean(self.get_argument('command', None)) + elif action == "start": + command = "start_server" + elif action == "stop": + command = "stop_server" + elif action == "restart": + command = "restart_server" + elif action == "backup": + command = "backup_server" + parent = bleach.clean(self.get_argument('parent', None)) + else: + interval_type = '' + cron_string = bleach.clean(self.get_argument('cron', '')) + sch_id = self.get_argument('sch_id', None) + try: + CronValidator.parse(cron_string) + except Exception as e: + self.redirect(f"/panel/error?error=INVALID FORMAT: Invalid Cron Format. {e}") + return + action = bleach.clean(self.get_argument('action', None)) + if action == "command": + command = bleach.clean(self.get_argument('command', None)) + elif action == "start": + command = "start_server" + elif action == "stop": + command = "stop_server" + elif action == "restart": + command = "restart_server" + elif action == "backup": + command = "backup_server" + if bleach.clean(self.get_argument('enabled', '0'))=='1': + enabled = True + else: + enabled = False + if bleach.clean(self.get_argument('one_time', '0')) == '1': + one_time = True + else: + one_time = False + + if not superuser and not permissions['Backup'] in self.controller.server_perms.get_user_id_permissions_list(exec_user["user_id"], + server_id): + self.redirect("/panel/error?error=Unauthorized access: User not authorized") + return + elif server_id is None: + self.redirect("/panel/error?error=Invalid Server ID") + return + else: + # does this server id exist? + if not self.controller.servers.server_id_exists(server_id): + self.redirect("/panel/error?error=Invalid Server ID") + return + + if interval_type == "days": + job_data = { + "server_id": server_id, + "action": action, + "interval_type": interval_type, + "interval": interval, + "command": command, + "start_time": sch_time, + "enabled": enabled, + "one_time": one_time, + "cron_string": '', + "parent": None, + "delay": 0 + } + elif difficulty == "advanced": + job_data = { + "server_id": server_id, + "action": action, + "interval_type": '', + "interval": '', + #We'll base every interval off of a midnight start time. + "start_time": '', + "command": command, + "cron_string": cron_string, + "delay": '', + "parent": '', + "enabled": enabled, + "one_time": one_time + } + elif difficulty == "reaction": + job_data = { + "server_id": server_id, + "action": action, + "interval_type": interval_type, + "interval": '', + #We'll base every interval off of a midnight start time. + "start_time": '', + "command": command, + "cron_string": '', + "enabled": enabled, + "one_time": one_time, + "parent": parent, + "delay": delay + } + else: + job_data = { + "server_id": server_id, + "action": action, + "interval_type": interval_type, + "interval": interval, + "command": command, + "enabled": enabled, + #We'll base every interval off of a midnight start time. + "start_time": '00:00', + "delay": '', + "parent": '', + "one_time": one_time, + "cron_string": '' + } + sch_id = self.get_argument('sch_id', None) + self.tasks_manager.update_job(sch_id, job_data) + + self.controller.management.add_to_audit_log(exec_user['user_id'], + f"Edited server {server_id}: updated schedule", + server_id, + self.get_remote_ip()) + self.tasks_manager.reload_schedule_from_db() + self.redirect(f"/panel/server_detail?id={server_id}&subpage=schedules") + elif page == "edit_user": + if bleach.clean(self.get_argument('username', None)) == 'system': + self.redirect("/panel/error?error=Unauthorized access: system user is not editable") user_id = bleach.clean(self.get_argument('id', None)) username = bleach.clean(self.get_argument('username', None)) password0 = bleach.clean(self.get_argument('password0', None)) password1 = bleach.clean(self.get_argument('password1', None)) + email = bleach.clean(self.get_argument('email', "default@example.com")) enabled = int(float(self.get_argument('enabled', '0'))) - regen_api = int(float(self.get_argument('regen_api', '0'))) - lang = bleach.clean(self.get_argument('language'), 'en_EN') + lang = bleach.clean(self.get_argument('language'), helper.get_setting('language')) + + if superuser: + #Checks if user is trying to change super user status of self. We don't want that. + # Automatically make them stay super user since we know they are. + if str(exec_user['user_id']) != str(user_id): + superuser = bleach.clean(self.get_argument('superuser', '0')) + else: + superuser = '1' + else: + superuser = '0' + if superuser == '1': + superuser = True + else: + superuser = False if Enum_Permissions_Crafty.User_Config not in exec_user_crafty_permissions: - if str(user_id) != str(exec_user_id): + if str(user_id) != str(exec_user["user_id"]): self.redirect("/panel/error?error=Unauthorized access: not a user editor") return @@ -734,8 +1453,7 @@ class PanelHandler(BaseHandler): self.controller.users.update_user(user_id, user_data=user_data) self.controller.management.add_to_audit_log(exec_user['user_id'], - "Edited user {} (UID:{}) password".format(username, - user_id), + f"Edited user {username} (UID:{user_id}) password", server_id=0, source_ip=self.get_remote_ip()) self.redirect("/panel/panel_config") @@ -756,44 +1474,20 @@ class PanelHandler(BaseHandler): self.redirect("/panel/error?error=Passwords must match") return - roles = set() - for role in self.controller.roles.get_all_roles(): - argument = int(float( - bleach.clean( - self.get_argument('role_{}_membership'.format(role.role_id), '0') - ) - )) - if argument: - roles.add(role.role_id) + roles = self.get_user_role_memberships() + permissions_mask, server_quantity = self.get_perms_quantity() - permissions_mask = "000" - server_quantity = {} - for permission in self.controller.crafty_perms.list_defined_crafty_permissions(): - argument = int(float( - bleach.clean( - self.get_argument('permission_{}'.format(permission.name), '0') - ) - )) - if argument: - permissions_mask = self.controller.crafty_perms.set_permission(permissions_mask, permission, argument) - - q_argument = int(float( - bleach.clean( - self.get_argument('quantity_{}'.format(permission.name), '0') - ) - )) - if q_argument: - server_quantity[permission.name] = q_argument - else: - server_quantity[permission.name] = 0 + # if email is None or "": + # email = "default@example.com" user_data = { "username": username, "password": password0, + "email": email, "enabled": enabled, - "regen_api": regen_api, "roles": roles, "lang": lang, + "superuser": superuser, } user_crafty_data = { "permissions_mask": permissions_mask, @@ -802,18 +1496,83 @@ class PanelHandler(BaseHandler): self.controller.users.update_user(user_id, user_data=user_data, user_crafty_data=user_crafty_data) self.controller.management.add_to_audit_log(exec_user['user_id'], - "Edited user {} (UID:{}) with roles {} and permissions {}".format(username, user_id, roles, permissions_mask), + f"Edited user {username} (UID:{user_id}) with roles {roles} and permissions {permissions_mask}", server_id=0, source_ip=self.get_remote_ip()) self.redirect("/panel/panel_config") + elif page == "edit_user_apikeys": + user_id = self.get_argument('id', None) + name = self.get_argument('name', None) + superuser = self.get_argument('superuser', None) == '1' + + if name is None or name == "": + self.redirect("/panel/error?error=Invalid API key name") + return + elif user_id is None: + self.redirect("/panel/error?error=Invalid User ID") + return + else: + # does this user id exist? + if not self.controller.users.user_id_exists(user_id): + self.redirect("/panel/error?error=Invalid User ID") + return + + crafty_permissions_mask = self.get_perms() + server_permissions_mask = self.get_perms_server() + + self.controller.users.add_user_api_key(name, user_id, superuser, crafty_permissions_mask, server_permissions_mask) + + self.controller.management.add_to_audit_log(exec_user['user_id'], + f"Added API key {name} with crafty permissions {crafty_permissions_mask}" + + f" and {server_permissions_mask} for user with UID: {user_id}", + server_id=0, + source_ip=self.get_remote_ip()) + self.redirect(f"/panel/edit_user_apikeys?id={user_id}") + + elif page == "get_token": + key_id = self.get_argument('id', None) + + if key_id is None: + self.redirect("/panel/error?error=Invalid Key ID") + return + else: + key = self.controller.users.get_user_api_key(key_id) + # does this user id exist? + if key is None: + self.redirect("/panel/error?error=Invalid Key ID") + return + + self.controller.management.add_to_audit_log(exec_user['user_id'], + f"Generated a new API token for the key {key.name} from user with UID: {key.user.user_id}", + server_id=0, + source_ip=self.get_remote_ip()) + + self.write(authentication.generate(key.user.user_id, { + 'token_id': key.token_id + })) + self.finish() + elif page == "add_user": + if bleach.clean(self.get_argument('username', None)).lower() == 'system': + self.redirect("/panel/error?error=Unauthorized access: username system is reserved for the Crafty system." + + " Please choose a different username.") + return username = bleach.clean(self.get_argument('username', None)) password0 = bleach.clean(self.get_argument('password0', None)) password1 = bleach.clean(self.get_argument('password1', None)) - enabled = int(float(self.get_argument('enabled', '0'))), - lang = bleach.clean(self.get_argument('lang', 'en_EN')) + email = bleach.clean(self.get_argument('email', "default@example.com")) + enabled = int(float(self.get_argument('enabled', '0'))) + lang = bleach.clean(self.get_argument('lang', helper.get_setting('language'))) + if superuser: + superuser = bleach.clean(self.get_argument('superuser', '0')) + else: + superuser = '0' + if superuser == '1': + superuser = True + else: + superuser = False if Enum_Permissions_Crafty.User_Config not in exec_user_crafty_permissions: self.redirect("/panel/error?error=Unauthorized access: not a user editor") @@ -831,41 +1590,14 @@ class PanelHandler(BaseHandler): self.redirect("/panel/error?error=Passwords must match") return - roles = set() - for role in self.controller.roles.get_all_roles(): - argument = int(float( - bleach.clean( - self.get_argument('role_{}_membership'.format(role.role_id), '0') - ) - )) - if argument: - roles.add(role.role_id) + roles = self.get_user_role_memberships() + permissions_mask, server_quantity = self.get_perms_quantity() - permissions_mask = "000" - server_quantity = {} - for permission in self.controller.crafty_perms.list_defined_crafty_permissions(): - argument = int(float( - bleach.clean( - self.get_argument('permission_{}'.format(permission.name), '0') - ) - )) - if argument: - permissions_mask = self.controller.crafty_perms.set_permission(permissions_mask, permission, argument) - - q_argument = int(float( - bleach.clean( - self.get_argument('quantity_{}'.format(permission.name), '0') - ) - )) - if q_argument: - server_quantity[permission.name] = q_argument - else: - server_quantity[permission.name] = 0 - - user_id = self.controller.users.add_user(username, password=password0, enabled=enabled) + user_id = self.controller.users.add_user(username, password=password0, email=email, enabled=enabled, superuser=superuser) user_data = { "roles": roles, - 'lang': lang + 'lang': lang, + 'lang_page': helper.getLangPage(lang), } user_crafty_data = { "permissions_mask": permissions_mask, @@ -874,11 +1606,11 @@ class PanelHandler(BaseHandler): self.controller.users.update_user(user_id, user_data=user_data, user_crafty_data=user_crafty_data) self.controller.management.add_to_audit_log(exec_user['user_id'], - "Added user {} (UID:{})".format(username, user_id), + f"Added user {username} (UID:{user_id})", server_id=0, source_ip=self.get_remote_ip()) self.controller.management.add_to_audit_log(exec_user['user_id'], - "Edited user {} (UID:{}) with roles {}".format(username, user_id, roles), + f"Edited user {username} (UID:{user_id}) with roles {roles}", server_id=0, source_ip=self.get_remote_ip()) self.redirect("/panel/panel_config") @@ -902,25 +1634,8 @@ class PanelHandler(BaseHandler): self.redirect("/panel/error?error=Invalid Role ID") return - servers = set() - for server in self.controller.list_defined_servers(): - argument = int(float( - bleach.clean( - self.get_argument('server_{}_access'.format(server['server_id']), '0') - ) - )) - if argument: - servers.add(server['server_id']) - - permissions_mask = "00000000" - for permission in self.controller.server_perms.list_defined_permissions(): - argument = int(float( - bleach.clean( - self.get_argument('permission_{}'.format(permission.name), '0') - ) - )) - if argument: - permissions_mask = self.controller.server_perms.set_permission(permissions_mask, permission, argument) + servers = self.get_role_servers() + permissions_mask = self.get_perms_server() role_data = { "role_name": role_name, @@ -929,7 +1644,7 @@ class PanelHandler(BaseHandler): self.controller.roles.update_role(role_id, role_data=role_data, permissions_mask=permissions_mask) self.controller.management.add_to_audit_log(exec_user['user_id'], - "Edited role {} (RID:{}) with servers {}".format(role_name, role_id, servers), + f"Edited role {role_name} (RID:{role_id}) with servers {servers}", server_id=0, source_ip=self.get_remote_ip()) self.redirect("/panel/panel_config") @@ -950,42 +1665,80 @@ class PanelHandler(BaseHandler): self.redirect("/panel/error?error=Role exists") return - servers = set() - for server in self.controller.list_defined_servers(): - argument = int(float( - bleach.clean( - self.get_argument('server_{}_access'.format(server['server_id']), '0') - ) - )) - if argument: - servers.add(server['server_id']) - - permissions_mask = "00000000" - for permission in self.controller.server_perms.list_defined_permissions(): - argument = int(float( - bleach.clean( - self.get_argument('permission_{}'.format(permission.name), '0') - ) - )) - if argument: - permissions_mask = self.controller.server_perms.set_permission(permissions_mask, permission, argument) + servers = self.get_role_servers() + permissions_mask = self.get_perms_server() role_id = self.controller.roles.add_role(role_name) self.controller.roles.update_role(role_id, {"servers": servers}, permissions_mask) self.controller.management.add_to_audit_log(exec_user['user_id'], - "Added role {} (RID:{})".format(role_name, role_id), + f"Added role {role_name} (RID:{role_id})", server_id=0, source_ip=self.get_remote_ip()) self.controller.management.add_to_audit_log(exec_user['user_id'], - "Edited role {} (RID:{}) with servers {}".format(role_name, role_id, servers), + f"Edited role {role_name} (RID:{role_id}) with servers {servers}", server_id=0, source_ip=self.get_remote_ip()) self.redirect("/panel/panel_config") else: self.set_status(404) + page_data = { + 'lang': helper.get_setting('language'), + 'lang_page': helper.getLangPage(helper.get_setting('language')), + } self.render( "public/404.html", translate=self.translator.translate, + data=page_data + ) + + @tornado.web.authenticated + def delete(self, page): + # pylint: disable=unused-variable + api_key, token_data, exec_user = self.current_user + superuser = exec_user['superuser'] + if api_key is not None: + superuser = superuser and api_key.superuser + + page_data = { + # todo: make this actually pull and compare version data + 'update_available': False, + 'version_data': helper.get_version_string(), + 'user_data': exec_user, + 'hosts_data': self.controller.management.get_latest_hosts_stats(), + 'show_contribute': helper.get_setting("show_contribute_link", True), + 'lang': self.controller.users.get_user_lang_by_id(exec_user["user_id"]), + 'lang_page': helper.getLangPage(self.controller.users.get_user_lang_by_id(exec_user["user_id"])), + } + + if page == "remove_apikey": + key_id = bleach.clean(self.get_argument('id', None)) + + if not superuser: + self.redirect("/panel/error?error=Unauthorized access: not superuser") + return + elif key_id is None or self.controller.users.get_user_api_key(key_id) is None: + self.redirect("/panel/error?error=Invalid Key ID") + return + else: + # does this user id exist? + target_key = self.controller.users.get_user_api_key(key_id) + if not target_key: + self.redirect("/panel/error?error=Invalid Key ID") + return + + self.controller.users.delete_user_api_key(key_id) + + self.controller.management.add_to_audit_log(exec_user['user_id'], + f"Removed API key {target_key} (ID: {key_id}) from user {exec_user['user_id']}", + server_id=0, + source_ip=self.get_remote_ip()) + self.redirect("/panel/panel_config") + else: + self.set_status(404) + self.render( + "public/404.html", + data=page_data, + translate=self.translator.translate, ) diff --git a/app/classes/web/public_handler.py b/app/classes/web/public_handler.py index c716d9ef..242b7da7 100644 --- a/app/classes/web/public_handler.py +++ b/app/classes/web/public_handler.py @@ -1,30 +1,22 @@ -import sys -import json import logging -import tornado.web -import tornado.escape - -from app.classes.shared.helpers import helper -from app.classes.web.base_handler import BaseHandler -from app.classes.shared.console import console -from app.classes.shared.main_models import fn from app.classes.models.users import Users - -logger = logging.getLogger(__name__) +from app.classes.shared.authentication import authentication +from app.classes.shared.helpers import helper +from app.classes.shared.main_models import fn +from app.classes.web.base_handler import BaseHandler try: import bleach except ModuleNotFoundError as e: - logger.critical("Import Error: Unable to load {} module".format(e.name), exc_info=True) - console.critical("Import Error: Unable to load {} module".format(e.name)) - sys.exit(1) + helper.auto_installer_fix(e) +logger = logging.getLogger(__name__) class PublicHandler(BaseHandler): - def set_current_user(self, user): + def set_current_user(self, user_id: str = None): expire_days = helper.get_setting('cookie_expire') @@ -32,21 +24,21 @@ class PublicHandler(BaseHandler): if not expire_days: expire_days = "5" - if user: - self.set_secure_cookie("user", tornado.escape.json_encode(user), expires_days=int(expire_days)) + if user_id is not None: + self.set_cookie("token", authentication.generate(user_id), expires_days=int(expire_days)) else: self.clear_cookie("user") def get(self, page=None): error = bleach.clean(self.get_argument('error', "Invalid Login!")) + error_msg = bleach.clean(self.get_argument('error_msg', '')) page_data = { 'version': helper.get_version_string(), - 'error': error - } - - page_data['lang'] = tornado.locale.get("en_EN") + 'error': error, 'lang': helper.get_setting('language'), + 'lang_page': helper.getLangPage(helper.get_setting('language')) + } # sensible defaults template = "public/404.html" @@ -75,6 +67,7 @@ class PublicHandler(BaseHandler): template, data=page_data, translate=self.translator.translate, + error_msg = error_msg ) def post(self, page=None): @@ -85,30 +78,32 @@ class PublicHandler(BaseHandler): entered_username = bleach.clean(self.get_argument('username')) entered_password = bleach.clean(self.get_argument('password')) + # pylint: disable=no-member user_data = Users.get_or_none(fn.Lower(Users.username) == entered_username.lower()) + # if we don't have a user if not user_data: - next_page = "/public/error?error=Login Failed" + error_msg = "Incorrect username or password. Please try again." self.clear_cookie("user") self.clear_cookie("user_data") - self.redirect(next_page) + self.redirect(f'/public/login?error_msg={error_msg}') return # if they are disabled if not user_data.enabled: - next_page = "/public/error?error=Login Failed" + error_msg = "User account disabled. Please contact your system administrator for more info." self.clear_cookie("user") self.clear_cookie("user_data") - self.redirect(next_page) + self.redirect(f'/public/login?error_msg={error_msg}') return login_result = helper.verify_pass(entered_password, user_data.password) # Valid Login if login_result: - self.set_current_user(entered_username) - logger.info("User: {} Logged in from IP: {}".format(user_data, self.get_remote_ip())) + self.set_current_user(user_data.user_id) + logger.info(f"User: {user_data} Logged in from IP: {self.get_remote_ip()}") # record this login q = Users.select().where(Users.username == entered_username.lower()).get() @@ -119,22 +114,14 @@ class PublicHandler(BaseHandler): # log this login self.controller.management.add_to_audit_log(user_data.user_id, "Logged in", 0, self.get_remote_ip()) - cookie_data = { - "username": user_data.username, - "user_id": user_data.user_id, - "account_type": user_data.superuser, - } - - self.set_secure_cookie('user_data', json.dumps(cookie_data)) - next_page = "/panel/dashboard" self.redirect(next_page) else: self.clear_cookie("user") self.clear_cookie("user_data") + error_msg = "Inncorrect username or password. Please try again." # log this failed login attempt self.controller.management.add_to_audit_log(user_data.user_id, "Tried to log in", 0, self.get_remote_ip()) - self.redirect('/public/error?error=Login Failed') + self.redirect(f'/public/login?error_msg={error_msg}') else: self.redirect("/public/login") - diff --git a/app/classes/web/server_handler.py b/app/classes/web/server_handler.py index 37bf1848..de900c07 100644 --- a/app/classes/web/server_handler.py +++ b/app/classes/web/server_handler.py @@ -1,40 +1,37 @@ -import sys import json import logging import os -import shutil -from app.classes.shared.console import console -from app.classes.web.base_handler import BaseHandler -from app.classes.models.crafty_permissions import Enum_Permissions_Crafty from app.classes.minecraft.serverjars import server_jar_obj +from app.classes.models.crafty_permissions import Enum_Permissions_Crafty from app.classes.shared.helpers import helper - - -logger = logging.getLogger(__name__) +from app.classes.shared.file_helpers import file_helper +from app.classes.web.base_handler import BaseHandler try: import tornado.web import tornado.escape import bleach + import libgravatar + import requests except ModuleNotFoundError as e: - logger.critical("Import Error: Unable to load {} module".format(e.name), exc_info=True) - console.critical("Import Error: Unable to load {} module".format(e.name)) - sys.exit(1) + helper.auto_installer_fix(e) +logger = logging.getLogger(__name__) class ServerHandler(BaseHandler): @tornado.web.authenticated def get(self, page): - # name = tornado.escape.json_decode(self.current_user) - exec_user_data = json.loads(self.get_secure_cookie("user_data")) - exec_user_id = exec_user_data['user_id'] - exec_user = self.controller.users.get_user_by_id(exec_user_id) + # pylint: disable=unused-variable + api_key, token_data, exec_user = self.current_user + superuser = exec_user['superuser'] + if api_key is not None: + superuser = superuser and api_key.superuser exec_user_role = set() - if exec_user['superuser'] == 1: + if superuser: defined_servers = self.controller.list_defined_servers() exec_user_role.add("Super User") exec_user_crafty_permissions = self.controller.crafty_perms.list_defined_crafty_permissions() @@ -42,8 +39,8 @@ class ServerHandler(BaseHandler): for role in self.controller.roles.get_all_roles(): list_roles.append(self.controller.roles.get_role(role.role_id)) else: - exec_user_crafty_permissions = self.controller.crafty_perms.get_crafty_permissions_list(exec_user_id) - defined_servers = self.controller.servers.get_authorized_servers(exec_user_id) + exec_user_crafty_permissions = self.controller.crafty_perms.get_crafty_permissions_list(exec_user["user_id"]) + defined_servers = self.controller.servers.get_authorized_servers(exec_user["user_id"]) list_roles = [] for r in exec_user['roles']: role = self.controller.roles.get_role(r) @@ -54,7 +51,7 @@ class ServerHandler(BaseHandler): page_data = { 'version_data': helper.get_version_string(), - 'user_data': exec_user_data, + 'user_data': exec_user, 'user_role' : exec_user_role, 'roles' : list_roles, 'user_crafty_permissions' : exec_user_crafty_permissions, @@ -71,20 +68,54 @@ class ServerHandler(BaseHandler): 'hosts_data': self.controller.management.get_latest_hosts_stats(), 'menu_servers': defined_servers, 'show_contribute': helper.get_setting("show_contribute_link", True), - 'lang': self.controller.users.get_user_lang_by_id(exec_user_id) + 'lang': self.controller.users.get_user_lang_by_id(exec_user["user_id"]), + 'lang_page': helper.getLangPage(self.controller.users.get_user_lang_by_id(exec_user["user_id"])), + 'api_key': { + 'name': api_key.name, + 'created': api_key.created, + 'server_permissions': api_key.server_permissions, + 'crafty_permissions': api_key.crafty_permissions, + 'superuser': api_key.superuser + } if api_key is not None else None, + 'superuser': superuser } - if exec_user['superuser'] == 1: + + if helper.get_setting("allow_nsfw_profile_pictures"): + rating = "x" + else: + rating = "g" + + + if exec_user['email'] != 'default@example.com' or "": + g = libgravatar.Gravatar(libgravatar.sanitize_email(exec_user['email'])) + url = g.get_image(size=80, default="404", force_default=False, rating=rating, filetype_extension=False, use_ssl=True) # + "?d=404" + if requests.head(url).status_code != 404: + profile_url = url + else: + profile_url = "/static/assets/images/faces-clipart/pic-3.png" + else: + profile_url = "/static/assets/images/faces-clipart/pic-3.png" + + page_data['user_image'] = profile_url + if superuser: page_data['roles'] = list_roles if page == "step1": - if not exec_user['superuser'] and not self.controller.crafty_perms.can_create_server(exec_user_id): + if not superuser and not self.controller.crafty_perms.can_create_server(exec_user["user_id"]): self.redirect("/panel/error?error=Unauthorized access: not a server creator or server limit reached") return - page_data['server_types'] = server_jar_obj.get_serverjar_data_sorted() - page_data['js_server_types'] = json.dumps(server_jar_obj.get_serverjar_data_sorted()) + page_data['server_types'] = server_jar_obj.get_serverjar_data() + page_data['js_server_types'] = json.dumps(server_jar_obj.get_serverjar_data()) template = "server/wizard.html" + if page == "bedrock_step1": + if not superuser and not self.controller.crafty_perms.can_create_server(exec_user["user_id"]): + self.redirect("/panel/error?error=Unauthorized access: not a server creator or server limit reached") + return + + template = "server/bedrock_wizard.html" + self.render( template, data=page_data, @@ -93,17 +124,19 @@ class ServerHandler(BaseHandler): @tornado.web.authenticated def post(self, page): - - exec_user_data = json.loads(self.get_secure_cookie("user_data")) - exec_user_id = exec_user_data['user_id'] - exec_user = self.controller.users.get_user_by_id(exec_user_id) + # pylint: disable=unused-variable + api_key, token_data, exec_user = self.current_user + superuser = exec_user['superuser'] + if api_key is not None: + superuser = superuser and api_key.superuser template = "public/404.html" page_data = { - 'version_data': "version_data_here", - 'user_data': exec_user_data, + 'version_data': "version_data_here", # TODO + 'user_data': exec_user, 'show_contribute': helper.get_setting("show_contribute_link", True), - 'lang': self.controller.users.get_user_lang_by_id(exec_user_id) + 'lang': self.controller.users.get_user_lang_by_id(exec_user["user_id"]), + 'lang_page': helper.getLangPage(self.controller.users.get_user_lang_by_id(exec_user["user_id"])) } if page == "command": @@ -125,7 +158,7 @@ class ServerHandler(BaseHandler): name_counter = 1 while is_name_used(new_server_name): name_counter += 1 - new_server_name = server_data.get('server_name') + " (Copy {})".format(name_counter) + new_server_name = server_data.get('server_name') + f" (Copy {name_counter})" new_server_uuid = helper.create_uuid() while os.path.exists(os.path.join(helper.servers_dir, new_server_uuid)): @@ -133,29 +166,36 @@ class ServerHandler(BaseHandler): new_server_path = os.path.join(helper.servers_dir, new_server_uuid) # copy the old server - shutil.copytree(server_data.get('path'), new_server_path) + file_helper.copy_dir(server_data.get('path'), new_server_path) # TODO get old server DB data to individual variables stop_command = server_data.get('stop_command') new_server_command = str(server_data.get('execution_command')).replace(server_uuid, new_server_uuid) new_executable = server_data.get('executable') - new_server_log_file = str(server_data.get('log_path')).replace(server_uuid, new_server_uuid) - auto_start = server_data.get('auto_start') - auto_start_delay = server_data.get('auto_start_delay') - crash_detection = server_data.get('crash_detection') + new_server_log_file = str(helper.get_os_understandable_path(server_data.get('log_path'))).replace(server_uuid, new_server_uuid) server_port = server_data.get('server_port') + server_type = server_data.get('type') - self.controller.servers.create_server(new_server_name, new_server_uuid, new_server_path, "", new_server_command, new_executable, new_server_log_file, stop_command, server_port) + self.controller.servers.create_server(new_server_name, + new_server_uuid, + new_server_path, + "", + new_server_command, + new_executable, + new_server_log_file, + stop_command, + server_type, + server_port) self.controller.init_all_servers() return - self.controller.management.send_command(exec_user_data['user_id'], server_id, self.get_remote_ip(), command) + self.controller.management.send_command(exec_user['user_id'], server_id, self.get_remote_ip(), command) if page == "step1": - if not exec_user['superuser']: + if not superuser: user_roles = self.controller.roles.get_all_roles() else: user_roles = self.controller.roles.get_all_roles() @@ -171,7 +211,7 @@ class ServerHandler(BaseHandler): captured_roles = [] for role in user_roles: if bleach.clean(self.get_argument(str(role), '')) == "on": - captured_roles.append(role) + captured_roles.append(role) if not server_name: self.redirect("/panel/error?error=Server name cannot be empty!") @@ -185,46 +225,51 @@ class ServerHandler(BaseHandler): return new_server_id = self.controller.import_jar_server(server_name, import_server_path,import_server_jar, min_mem, max_mem, port) - self.controller.management.add_to_audit_log(exec_user_data['user_id'], - "imported a jar server named \"{}\"".format(server_name), # Example: Admin imported a server named "old creative" + self.controller.management.add_to_audit_log(exec_user['user_id'], + f"imported a jar server named \"{server_name}\"", # Example: Admin imported a server named "old creative" new_server_id, self.get_remote_ip()) elif import_type == 'import_zip': # here import_server_path means the zip path - good_path = self.controller.verify_zip_server(import_server_path) + zip_path = bleach.clean(self.get_argument('root_path')) + good_path = helper.check_path_exists(zip_path) if not good_path: - self.redirect("/panel/error?error=Zip file not found!") + self.redirect("/panel/error?error=Temp path not found!") return - new_server_id = self.controller.import_zip_server(server_name, import_server_path,import_server_jar, min_mem, max_mem, port) + new_server_id = self.controller.import_zip_server(server_name, zip_path, import_server_jar, min_mem, max_mem, port) if new_server_id == "false": - self.redirect("/panel/error?error=Zip file not accessible! You can fix this permissions issue with sudo chown -R crafty:crafty {} And sudo chmod 2775 -R {}".format(import_server_path, import_server_path)) + self.redirect("/panel/error?error=Zip file not accessible! You can fix this permissions issue with" + + f"sudo chown -R crafty:crafty {import_server_path} And sudo chmod 2775 -R {import_server_path}") return - self.controller.management.add_to_audit_log(exec_user_data['user_id'], - "imported a zip server named \"{}\"".format(server_name), # Example: Admin imported a server named "old creative" + self.controller.management.add_to_audit_log(exec_user['user_id'], + f"imported a zip server named \"{server_name}\"", # Example: Admin imported a server named "old creative" new_server_id, self.get_remote_ip()) + #deletes temp dir + file_helper.del_dirs(zip_path) else: if len(server_parts) != 2: self.redirect("/panel/error?error=Invalid server data") return server_type, server_version = server_parts # TODO: add server type check here and call the correct server add functions if not a jar - role_ids = self.controller.users.get_user_roles_id(exec_user_id) + role_ids = self.controller.users.get_user_roles_id(exec_user["user_id"]) new_server_id = self.controller.create_jar_server(server_type, server_version, server_name, min_mem, max_mem, port) - self.controller.management.add_to_audit_log(exec_user_data['user_id'], - "created a {} {} server named \"{}\"".format(server_version, str(server_type).capitalize(), server_name), # Example: Admin created a 1.16.5 Bukkit server named "survival" + self.controller.management.add_to_audit_log(exec_user['user_id'], + f"created a {server_version} {str(server_type).capitalize()} server named \"{server_name}\"", + # Example: Admin created a 1.16.5 Bukkit server named "survival" new_server_id, self.get_remote_ip()) # These lines create a new Role for the Server with full permissions and add the user to it if he's not a superuser if len(captured_roles) == 0: - if not exec_user['superuser']: + if not superuser: new_server_uuid = self.controller.servers.get_server_data_by_id(new_server_id).get("server_uuid") - role_id = self.controller.roles.add_role("Creator of Server with uuid={}".format(new_server_uuid)) + role_id = self.controller.roles.add_role(f"Creator of Server with uuid={new_server_uuid}") self.controller.server_perms.add_role_server(new_server_id, role_id, "11111111") - self.controller.users.add_role_to_user(exec_user_id, role_id) - self.controller.crafty_perms.add_server_creation(exec_user_id) + self.controller.users.add_role_to_user(exec_user["user_id"], role_id) + self.controller.crafty_perms.add_server_creation(exec_user["user_id"]) else: for role in captured_roles: @@ -234,8 +279,94 @@ class ServerHandler(BaseHandler): self.controller.stats.record_stats() self.redirect("/panel/dashboard") - self.render( - template, - data=page_data, - translate=self.translator.translate, - ) + if page == "bedrock_step1": + if not superuser: + user_roles = self.controller.roles.get_all_roles() + else: + user_roles = self.controller.roles.get_all_roles() + server = bleach.clean(self.get_argument('server', '')) + server_name = bleach.clean(self.get_argument('server_name', '')) + port = bleach.clean(self.get_argument('port', '')) + import_type = bleach.clean(self.get_argument('create_type', '')) + import_server_path = bleach.clean(self.get_argument('server_path', '')) + import_server_exe = bleach.clean(self.get_argument('server_jar', '')) + server_parts = server.split("|") + captured_roles = [] + for role in user_roles: + if bleach.clean(self.get_argument(str(role), '')) == "on": + captured_roles.append(role) + + if not server_name: + self.redirect("/panel/error?error=Server name cannot be empty!") + return + + if import_type == 'import_jar': + good_path = self.controller.verify_jar_server(import_server_path, import_server_exe) + + if not good_path: + self.redirect("/panel/error?error=Server path or Server Jar not found!") + return + + new_server_id = self.controller.import_bedrock_server(server_name, import_server_path,import_server_exe, port) + self.controller.management.add_to_audit_log(exec_user['user_id'], + f"imported a jar server named \"{server_name}\"", # Example: Admin imported a server named "old creative" + new_server_id, + self.get_remote_ip()) + elif import_type == 'import_zip': + # here import_server_path means the zip path + zip_path = bleach.clean(self.get_argument('root_path')) + good_path = helper.check_path_exists(zip_path) + if not good_path: + self.redirect("/panel/error?error=Temp path not found!") + return + + new_server_id = self.controller.import_bedrock_zip_server(server_name, zip_path, import_server_exe, port) + if new_server_id == "false": + self.redirect("/panel/error?error=Zip file not accessible! You can fix this permissions issue with" + + f"sudo chown -R crafty:crafty {import_server_path} And sudo chmod 2775 -R {import_server_path}") + return + self.controller.management.add_to_audit_log(exec_user['user_id'], + f"imported a zip server named \"{server_name}\"", # Example: Admin imported a server named "old creative" + new_server_id, + self.get_remote_ip()) + #deletes temp dir + file_helper.del_dirs(zip_path) + else: + if len(server_parts) != 2: + self.redirect("/panel/error?error=Invalid server data") + return + server_type, server_version = server_parts + # TODO: add server type check here and call the correct server add functions if not a jar + role_ids = self.controller.users.get_user_roles_id(exec_user["user_id"]) + new_server_id = self.controller.create_jar_server(server_type, server_version, server_name, min_mem, max_mem, port) + self.controller.management.add_to_audit_log(exec_user['user_id'], + f"created a {server_version} {str(server_type).capitalize()} server named \"{server_name}\"", + # Example: Admin created a 1.16.5 Bukkit server named "survival" + new_server_id, + self.get_remote_ip()) + + # These lines create a new Role for the Server with full permissions and add the user to it if he's not a superuser + if len(captured_roles) == 0: + if not superuser: + new_server_uuid = self.controller.servers.get_server_data_by_id(new_server_id).get("server_uuid") + role_id = self.controller.roles.add_role(f"Creator of Server with uuid={new_server_uuid}") + self.controller.server_perms.add_role_server(new_server_id, role_id, "11111111") + self.controller.users.add_role_to_user(exec_user["user_id"], role_id) + self.controller.crafty_perms.add_server_creation(exec_user["user_id"]) + + else: + for role in captured_roles: + role_id = role + self.controller.server_perms.add_role_server(new_server_id, role_id, "11111111") + + self.controller.stats.record_stats() + self.redirect("/panel/dashboard") + + try: + self.render( + template, + data=page_data, + translate=self.translator.translate, + ) + except RuntimeError: + self.redirect('/panel/dashboard') diff --git a/app/classes/web/static_handler.py b/app/classes/web/static_handler.py index 27855434..4d401ddb 100644 --- a/app/classes/web/static_handler.py +++ b/app/classes/web/static_handler.py @@ -1,9 +1,11 @@ -import tornado.web -from typing import ( - Optional -) +from typing import ( Optional ) -from app.classes.shared.console import console +try: + import tornado.web + +except ModuleNotFoundError as e: + from app.classes.shared.helpers import helper + helper.auto_installer_fix(e) class CustomStaticHandler(tornado.web.StaticFileHandler): def validate_absolute_path(self, root: str, absolute_path: str) -> Optional[str]: @@ -12,4 +14,4 @@ class CustomStaticHandler(tornado.web.StaticFileHandler): except tornado.web.HTTPError as error: if 'HTTP 404: Not Found' in str(error): self.set_status(404) - self.finish({'error':'NOT_FOUND', 'info':'The requested resource was not found on the server'}) \ No newline at end of file + self.finish({'error':'NOT_FOUND', 'info':'The requested resource was not found on the server'}) diff --git a/app/classes/web/status_handler.py b/app/classes/web/status_handler.py index be0e3d05..07a488ca 100644 --- a/app/classes/web/status_handler.py +++ b/app/classes/web/status_handler.py @@ -1,36 +1,27 @@ -from re import template -import sys -import json import logging -import tornado.web -import tornado.escape -import requests from app.classes.shared.helpers import helper from app.classes.web.base_handler import BaseHandler -from app.classes.shared.console import console -from app.classes.shared.main_models import fn logger = logging.getLogger(__name__) -try: - import bleach - -except ModuleNotFoundError as e: - logger.critical("Import Error: Unable to load {} module".format(e.name), exc_info=True) - console.critical("Import Error: Unable to load {} module".format(e.name)) - sys.exit(1) - - class StatusHandler(BaseHandler): def get(self): page_data = {} - page_data['lang'] = tornado.locale.get("en_EN") + page_data['lang'] = helper.get_setting('language') + page_data['lang_page'] = helper.getLangPage(helper.get_setting('language')) page_data['servers'] = self.controller.servers.get_all_servers_stats() + running = 0 for srv in page_data['servers']: + if srv['stats']['running']: + running += 1 server_data = srv.get('server_data', False) server_id = server_data.get('server_id', False) - srv['raw_ping_result'] = self.controller.stats.get_raw_server_stats(server_id) + srv['raw_ping_result'] = self.controller.servers.get_server_stats_by_id(server_id) + if 'icon' not in srv['raw_ping_result']: + srv['raw_ping_result']['icon'] = False + + page_data['running'] = running template = 'public/status.html' @@ -45,12 +36,11 @@ class StatusHandler(BaseHandler): for srv in page_data['servers']: server_data = srv.get('server_data', False) server_id = server_data.get('server_id', False) - srv['raw_ping_result'] = self.controller.stats.get_raw_server_stats(server_id) - + srv['raw_ping_result'] = self.controller.servers.get_server_stats_by_id(server_id) template = 'public/status.html' self.render( template, data=page_data, translate=self.translator.translate, - ) \ No newline at end of file + ) diff --git a/app/classes/web/tornado.py b/app/classes/web/tornado_handler.py similarity index 78% rename from app/classes/web/tornado.py rename to app/classes/web/tornado_handler.py index 4e4e5866..ca411751 100644 --- a/app/classes/web/tornado.py +++ b/app/classes/web/tornado_handler.py @@ -3,12 +3,22 @@ import sys import json import asyncio import logging -import threading +from app.classes.shared.translation import translation from app.classes.shared.console import console from app.classes.shared.helpers import helper - -logger = logging.getLogger(__name__) +from app.classes.web.file_handler import FileHandler +from app.classes.web.public_handler import PublicHandler +from app.classes.web.panel_handler import PanelHandler +from app.classes.web.default_handler import DefaultHandler +from app.classes.web.server_handler import ServerHandler +from app.classes.web.ajax_handler import AjaxHandler +from app.classes.web.api_handler import ServersStats, NodeStats +from app.classes.web.websocket_handler import SocketHandler +from app.classes.web.static_handler import CustomStaticHandler +from app.classes.web.upload_handler import UploadHandler +from app.classes.web.http_handler import HTTPHandler, HTTPHandlerPage +from app.classes.web.status_handler import StatusHandler try: import tornado.web @@ -18,25 +28,11 @@ try: import tornado.escape import tornado.locale import tornado.httpserver - from app.classes.web.public_handler import PublicHandler - from app.classes.web.panel_handler import PanelHandler - from app.classes.web.default_handler import DefaultHandler - from app.classes.web.server_handler import ServerHandler - from app.classes.web.ajax_handler import AjaxHandler - from app.classes.web.api_handler import ServersStats, NodeStats - from app.classes.web.websocket_handler import SocketHandler - from app.classes.web.static_handler import CustomStaticHandler - from app.classes.shared.translation import translation - from app.classes.web.upload_handler import UploadHandler - from app.classes.web.http_handler import HTTPHandler, HTTPHandlerPage - from app.classes.web.status_handler import StatusHandler except ModuleNotFoundError as e: - logger.critical("Import Error: Unable to load {} module".format(e, e.name)) - console.critical("Import Error: Unable to load {} module".format(e, e.name)) - sys.exit(1) - + helper.auto_installer_fix(e) +logger = logging.getLogger(__name__) class Webserver: @@ -48,7 +44,6 @@ class Webserver: self.tasks_manager = tasks_manager self._asyncio_patch() - @staticmethod def log_function(handler): @@ -57,6 +52,7 @@ class Webserver: 'Method': handler.request.method, 'URL': handler.request.uri, 'Remote_IP': handler.request.remote_ip, + # pylint: disable=consider-using-f-string 'Elapsed_Time': '%.2fms' % (handler.request.request_time() * 1000) } @@ -74,12 +70,12 @@ class Webserver: """ logger.debug("Checking if asyncio patch is required") if sys.platform.startswith("win") and sys.version_info >= (3, 8): + # pylint: disable=reimported,import-outside-toplevel,redefined-outer-name import asyncio try: from asyncio import WindowsSelectorEventLoopPolicy except ImportError: - logger.debug("asyncio patch isn't required") - pass # Can't assign a policy which doesn't exist. + logger.debug("asyncio patch isn't required") # Can't assign a policy which doesn't exist. else: if not isinstance(asyncio.get_event_loop_policy(), WindowsSelectorEventLoopPolicy): asyncio.set_event_loop_policy(WindowsSelectorEventLoopPolicy()) @@ -92,7 +88,7 @@ class Webserver: http_port = helper.get_setting('http_port') https_port = helper.get_setting('https_port') - + debug_errors = helper.get_setting('show_errors') cookie_secret = helper.get_setting('cookie_secret') @@ -110,12 +106,13 @@ class Webserver: 'keyfile': os.path.join(helper.config_dir, 'web', 'certs', 'commander.key.pem'), } - logger.info("Starting Web Server on ports http:{} https:{}".format(http_port, https_port)) + logger.info(f"Starting Web Server on ports http:{http_port} https:{https_port}") asyncio.set_event_loop(asyncio.new_event_loop()) tornado.template.Loader('.') + # TODO: Remove because we don't and won't use tornado.locale.set_default_locale('en_EN') handler_args = {"controller": self.controller, "tasks_manager": self.tasks_manager, "translator": translation} @@ -125,6 +122,7 @@ class Webserver: (r'/panel/(.*)', PanelHandler, handler_args), (r'/server/(.*)', ServerHandler, handler_args), (r'/ajax/(.*)', AjaxHandler, handler_args), + (r'/files/(.*)', FileHandler, handler_args), (r'/api/stats/servers', ServersStats, handler_args), (r'/api/stats/node', NodeStats, handler_args), (r'/ws', SocketHandler, handler_args), @@ -175,10 +173,8 @@ class Webserver: self.HTTPS_Server = tornado.httpserver.HTTPServer(app, ssl_options=cert_objects) self.HTTPS_Server.listen(https_port) - logger.info("http://{}:{} is up and ready for connections.".format(helper.get_local_ip(), http_port)) - logger.info("https://{}:{} is up and ready for connections.".format(helper.get_local_ip(), https_port)) - console.info("http://{}:{} is up and ready for connections.".format(helper.get_local_ip(), http_port)) - console.info("https://{}:{} is up and ready for connections.".format(helper.get_local_ip(), https_port)) + logger.info(f"https://{helper.get_local_ip()}:{https_port} is up and ready for connections.") + console.info(f"https://{helper.get_local_ip()}:{https_port} is up and ready for connections.") console.info("Server Init Complete: Listening For Connections:") diff --git a/app/classes/web/upload_handler.py b/app/classes/web/upload_handler.py index 0d0af62d..2702593e 100644 --- a/app/classes/web/upload_handler.py +++ b/app/classes/web/upload_handler.py @@ -1,25 +1,31 @@ -from app.classes.shared.main_controller import Controller -import tornado.options -import tornado.web -import tornado.httpserver -from tornado.options import options -from app.classes.models.server_permissions import Enum_Permissions_Server -from app.classes.shared.helpers import helper -from app.classes.web.websocket_helper import websocket_helper -from app.classes.shared.console import console import logging import os -import json import time +from app.classes.models.server_permissions import Enum_Permissions_Server +from app.classes.shared.helpers import helper +from app.classes.shared.console import console +from app.classes.shared.main_controller import Controller +from app.classes.web.websocket_helper import websocket_helper +from app.classes.web.base_handler import BaseHandler + +try: + import tornado.web + import tornado.options + import tornado.httpserver + +except ModuleNotFoundError as ex: + helper.auto_installer_fix(ex) + logger = logging.getLogger(__name__) -# Class&Function Defination +# Class & Function Defination MAX_STREAMED_SIZE = 1024 * 1024 * 1024 @tornado.web.stream_request_body -class UploadHandler(tornado.web.RequestHandler): +class UploadHandler(BaseHandler): + # noinspection PyAttributeOutsideInit def initialize(self, controller: Controller=None, tasks_manager=None, translator=None): self.controller = controller self.tasks_manager = tasks_manager @@ -27,8 +33,21 @@ class UploadHandler(tornado.web.RequestHandler): def prepare(self): self.do_upload = True - user_data = json.loads(self.get_secure_cookie('user_data')) - user_id = user_data['user_id'] + # pylint: disable=unused-variable + api_key, token_data, exec_user = self.current_user + server_id = self.get_argument('server_id', None) + superuser = exec_user['superuser'] + if api_key is not None: + superuser = superuser and api_key.superuser + user_id = exec_user['user_id'] + + if superuser: + exec_user_server_permissions = self.controller.server_perms.list_defined_permissions() + elif api_key is not None: + exec_user_server_permissions = self.controller.server_perms.get_api_key_permissions_list(api_key, server_id) + else: + exec_user_server_permissions = self.controller.server_perms.get_user_id_permissions_list( + exec_user["user_id"], server_id) server_id = self.request.headers.get('X-ServerId', None) @@ -42,8 +61,7 @@ class UploadHandler(tornado.web.RequestHandler): console.warning('Server ID not found in upload handler call') self.do_upload = False - user_permissions = self.controller.server_perms.get_user_permissions_list(user_id, server_id) - if Enum_Permissions_Server.Files not in user_permissions: + if Enum_Permissions_Server.Files not in exec_user_server_permissions: logger.warning(f'User {user_id} tried to upload a file to {server_id} without permissions!') console.warning(f'User {user_id} tried to upload a file to {server_id} without permissions!') self.do_upload = False @@ -52,8 +70,8 @@ class UploadHandler(tornado.web.RequestHandler): filename = self.request.headers.get('X-FileName', None) full_path = os.path.join(path, filename) - if not helper.in_path(self.controller.servers.get_server_data_by_id(server_id)['path'], full_path): - print(user_id, server_id, self.controller.servers.get_server_data_by_id(server_id)['path'], full_path) + if not helper.in_path(helper.get_os_understandable_path(self.controller.servers.get_server_data_by_id(server_id)['path']), full_path): + print(user_id, server_id, helper.get_os_understandable_path(self.controller.servers.get_server_data_by_id(server_id)['path']), full_path) logger.warning(f'User {user_id} tried to upload a file to {server_id} but the path is not inside of the server!') console.warning(f'User {user_id} tried to upload a file to {server_id} but the path is not inside of the server!') self.do_upload = False @@ -62,7 +80,7 @@ class UploadHandler(tornado.web.RequestHandler): try: self.f = open(full_path, "wb") except Exception as e: - logger.error("Upload failed with error: {}".format(e)) + logger.error(f"Upload failed with error: {e}") self.do_upload = False # If max_body_size is not set, you cannot upload files > 100MB self.request.connection.set_max_body_size(MAX_STREAMED_SIZE) @@ -83,6 +101,6 @@ class UploadHandler(tornado.web.RequestHandler): websocket_helper.broadcast('close_upload_box', 'error') self.finish('error') - def data_received(self, data): + def data_received(self, chunk): if self.do_upload: - self.f.write(data) + self.f.write(chunk) diff --git a/app/classes/web/websocket_handler.py b/app/classes/web/websocket_handler.py index e1c1a737..d8146fd3 100644 --- a/app/classes/web/websocket_handler.py +++ b/app/classes/web/websocket_handler.py @@ -1,23 +1,27 @@ import json import logging import asyncio - from urllib.parse import parse_qsl -from app.classes.models.users import Users + +from app.classes.shared.authentication import authentication from app.classes.shared.helpers import helper from app.classes.web.websocket_helper import websocket_helper -logger = logging.getLogger(__name__) - try: import tornado.websocket except ModuleNotFoundError as e: - logger.critical("Import Error: Unable to load {} module".format(e, e.name)) - console.critical("Import Error: Unable to load {} module".format(e, e.name)) - sys.exit(1) + helper.auto_installer_fix(e) + +logger = logging.getLogger(__name__) class SocketHandler(tornado.websocket.WebSocketHandler): + page = None + page_query_params = None + controller = None + tasks_manager = None + translator = None + io_loop = None def initialize(self, controller=None, tasks_manager=None, translator=None): self.controller = controller @@ -31,18 +35,14 @@ class SocketHandler(tornado.websocket.WebSocketHandler): self.request.remote_ip return remote_ip + def get_user_id(self): + _, _, user = authentication.check(self.get_cookie('token')) + return user['user_id'] + def check_auth(self): - user_data_cookie_raw = self.get_secure_cookie('user_data') - - if user_data_cookie_raw and user_data_cookie_raw.decode('utf-8'): - user_data_cookie = user_data_cookie_raw.decode('utf-8') - user_id = json.loads(user_data_cookie)['user_id'] - query = Users.select().where(Users.user_id == user_id) - if query.exists(): - return True - return False - + return authentication.check_bool(self.get_cookie('token')) + # pylint: disable=arguments-differ def open(self): logger.debug('Checking WebSocket authentication') if self.check_auth(): @@ -50,7 +50,10 @@ class SocketHandler(tornado.websocket.WebSocketHandler): else: websocket_helper.send_message(self, 'notification', 'Not authenticated for WebSocket connection') self.close() - self.controller.management.add_to_audit_log_raw('unknown', 0, 0, 'Someone tried to connect via WebSocket without proper authentication', self.get_remote_ip()) + self.controller.management.add_to_audit_log_raw('unknown', + 0, 0, + 'Someone tried to connect via WebSocket without proper authentication', + self.get_remote_ip()) websocket_helper.broadcast('notification', 'Someone tried to connect via WebSocket without proper authentication') logger.warning('Someone tried to connect via WebSocket without proper authentication') @@ -62,22 +65,21 @@ class SocketHandler(tornado.websocket.WebSocketHandler): ))) websocket_helper.add_client(self) logger.debug('Opened WebSocket connection') - # websocket_helper.broadcast('notification', 'New client connected') - def on_message(self, rawMessage): + # pylint: disable=arguments-renamed + @staticmethod + def on_message(raw_message): - logger.debug('Got message from WebSocket connection {}'.format(rawMessage)) - message = json.loads(rawMessage) - logger.debug('Event Type: {}, Data: {}'.format(message['event'], message['data'])) + logger.debug(f'Got message from WebSocket connection {raw_message}') + message = json.loads(raw_message) + logger.debug(f"Event Type: {message['event']}, Data: {message['data']}") def on_close(self): websocket_helper.remove_client(self) logger.debug('Closed WebSocket connection') - # websocket_helper.broadcast('notification', 'Client disconnected') async def write_message_int(self, message): self.write_message(message) - + def write_message_helper(self, message): asyncio.run_coroutine_threadsafe(self.write_message_int(message), self.io_loop.asyncio_loop) - diff --git a/app/classes/web/websocket_helper.py b/app/classes/web/websocket_helper.py index 99935ad3..c124e351 100644 --- a/app/classes/web/websocket_helper.py +++ b/app/classes/web/websocket_helper.py @@ -1,57 +1,69 @@ import json import logging -import sys, threading, asyncio from app.classes.shared.console import console logger = logging.getLogger(__name__) - -try: - import tornado.ioloop - -except ModuleNotFoundError as e: - logger.critical("Import Error: Unable to load {} module".format(e, e.name)) - console.critical("Import Error: Unable to load {} module".format(e, e.name)) - sys.exit(1) - class WebSocketHelper: def __init__(self): self.clients = set() def add_client(self, client): self.clients.add(client) - + def remove_client(self, client): self.clients.remove(client) + # pylint: disable=no-self-use def send_message(self, client, event_type: str, data): if client.check_auth(): message = str(json.dumps({'event': event_type, 'data': data})) client.write_message_helper(message) def broadcast(self, event_type: str, data): - logger.debug('Sending to {} clients: {}'.format(len(self.clients), json.dumps({'event': event_type, 'data': data}))) + logger.debug(f"Sending to {len(self.clients)} clients: {json.dumps({'event': event_type, 'data': data})}") for client in self.clients: try: self.send_message(client, event_type, data) except Exception as e: - logger.exception('Error catched while sending WebSocket message to {}'.format(client.get_remote_ip())) + logger.exception(f'Error caught while sending WebSocket message to {client.get_remote_ip()} {e}') def broadcast_page(self, page: str, event_type: str, data): def filter_fn(client): return client.page == page - clients = list(filter(filter_fn, self.clients)) + self.broadcast_with_fn(filter_fn, event_type, data) - logger.debug('Sending to {} out of {} clients: {}'.format(len(clients), len(self.clients), json.dumps({'event': event_type, 'data': data}))) + def broadcast_user(self, user_id: str, event_type: str, data): + def filter_fn(client): + return client.get_user_id() == user_id + + self.broadcast_with_fn(filter_fn, event_type, data) + + def broadcast_user_page(self, page: str, user_id: str, event_type: str, data): + def filter_fn(client): + if client.get_user_id() != user_id: + return False + if client.page != page: + return False + return True + + self.broadcast_with_fn(filter_fn, event_type, data) + + def broadcast_user_page_params(self, page: str, params: dict, user_id: str, event_type: str, data): + def filter_fn(client): + if client.get_user_id() != user_id: + return False + if client.page != page: + return False + for key, param in params.items(): + if param != client.page_query_params.get(key, None): + return False + return True + + self.broadcast_with_fn(filter_fn, event_type, data) - for client in clients: - try: - self.send_message(client, event_type, data) - except Exception as e: - logger.exception('Error catched while sending WebSocket message to {}'.format(client.get_remote_ip())) - def broadcast_page_params(self, page: str, params: dict, event_type: str, data): def filter_fn(client): if client.page != page: @@ -61,20 +73,22 @@ class WebSocketHelper: return False return True - clients = list(filter(filter_fn, self.clients)) + self.broadcast_with_fn(filter_fn, event_type, data) - logger.debug('Sending to {} out of {} clients: {}'.format(len(clients), len(self.clients), json.dumps({'event': event_type, 'data': data}))) + def broadcast_with_fn(self, filter_fn, event_type: str, data): + clients = list(filter(filter_fn, self.clients)) + logger.debug(f"Sending to {len(clients)} out of {len(self.clients)} clients: {json.dumps({'event': event_type, 'data': data})}") for client in clients: try: self.send_message(client, event_type, data) except Exception as e: - logger.exception('Error catched while sending WebSocket message to {}'.format(client.get_remote_ip())) - + logger.exception(f'Error catched while sending WebSocket message to {client.get_remote_ip()} {e}') + def disconnect_all(self): console.info('Disconnecting WebSocket clients') for client in self.clients: client.close() console.info('Disconnected WebSocket clients') -websocket_helper = WebSocketHelper() \ No newline at end of file +websocket_helper = WebSocketHelper() diff --git a/app/config/config.json b/app/config/config.json index 27f08bb8..aad33f03 100644 --- a/app/config/config.json +++ b/app/config/config.json @@ -5,6 +5,7 @@ "language": "en_EN", "cookie_expire": 30, "cookie_secret": "random", + "apikey_secret": "random", "show_errors": true, "history_max_age": 7, "stats_update_frequency": 30, @@ -12,5 +13,8 @@ "show_contribute_link": true, "virtual_terminal_lines": 70, "max_log_lines": 700, - "keywords": ["help", "chunk"] + "max_audit_entries": 300, + "disabled_language_files": ["lol_EN.json", ""], + "keywords": ["help", "chunk"], + "allow_nsfw_profile_pictures": false } diff --git a/app/config/credits.json b/app/config/credits.json index a2ec9e62..9417c784 100644 --- a/app/config/credits.json +++ b/app/config/credits.json @@ -1,301 +1,140 @@ { "staff": { - "development": [ - { - "name": "Phil Tarrant", - "title": "Creator", - "loc": "Southeast, USA", - "tags": [ - "Staff", - [ - "Developer", - "https://gitlab.com/Ptarrant1" - ], - "Creator" - ], - "blurb": "For best results, apply a thin layer of Phillip to code, cyber security, and parenthood for maximum effectiveness. Phillip often spends too much time with his chickens.", - "pic": "/static/assets/images/credits/ptarrant.png" - }, - { - "name": "Pita Bread", - "title": "Leadership Team", - "loc": "Midwest, USA", - "tags": [ - "Staff", - [ - "Developer", - "https://gitlab.com/craftbreadth" - ], - "Community Leader" - ], - "blurb": "Baked goods enthusiast who dabbles in Linux and sets up networks for fun. Can be found writing long-winded emails, debugging strange wifi issues, or taking care of his extensive houseplant collection.", - "pic": "/static/assets/images/credits/pita.png" - }, - { - "name": "macgeek", - "title": "Leadership Team", - "loc": "Midwest, USA", - "tags": [ - "Staff", - [ - "Developer", - "https://gitlab.com/computergeek125" - ], - "Project Manager" - ], - "blurb": "Sysadmin for work and sysadmin for fun (avid homelabber). He enjoys a good tech tangent and gaming.", - "pic": "/static/assets/images/credits/macgeek.png" - }, - { - "name": "parzivaldewey", - "title": null, - "loc": "East Coast, USA", - "tags": [ - "Staff", - [ - "Developer", - "https://gitlab.com/amcmanu3" - ], - "Support Manager" - ], - "blurb": "His interests include Linux, gaming, and helping others. When he's able to unplug he enjoys biking, hiking, and playing soccer.", - "pic": "/static/assets/images/credits/parzivaldewey.png" - }, - { - "name": "Xithical", - "title": null, - "loc": "Midwest, USA", - "tags": [ - "Staff", - [ - "Developer", - "https://gitlab.com/xithical" - ], - null - ], - "blurb": "Having sold his soul to the IT administration gods many eons ago, you can usually find him working, going home to do work, or continuing to work in the support Discord.", - "pic": "/static/assets/images/credits/xithical.png" - }, - { - "name": "MC Gaming", - "title": null, - "loc": "Central, UK", - "tags": [ - "Staff", - [ - "Developer", - "https://gitlab.com/MCgamin1738" - ], - null - ], - "blurb": "His interests include learning, Linux, and programming. He loves pentesting apps and gaming.", - "pic": "/static/assets/images/credits/mcgaming.png" - }, - { - "name": "Silversthorn", - "title": null, - "loc": "Provence, FR", - "tags": [ - "Staff", - [ - "Developer", - "https://gitlab.com/Silversthorn" - ], - null - ], - "blurb": "Developper at work and at home, testing his own code is a pain, so his coding precept is \"Testing is Doubting\"", - "pic": "/static/assets/images/credits/silversthorn.png" - }, - { - "name": "ThatOneLukas", - "title": null, - "loc": "Helsinki, FI", - "tags": [ - "Staff", - [ - "Developer", - "https://gitlab.com/LukasDoesDev" - ], - null - ], - "blurb": "Lukas enjoys bashing his head at the table while his code does not work", - "pic": "/static/assets/images/credits/lukas.png" - }, - { - "name": "Zedifus", - "title": null, - "loc": "Scotland, UK", - "tags": [ - "Staff", - [ - "Developer", - "https://gitlab.com/Zedifus" - ], - "DevOps" - ], - "blurb": "A Streamer, who is currently working in the insurance industry, self-teaching software development & DevOps, with the hopes of a career change! Enjoys playing games and has a sassy cat called Eve.", - "pic": "/static/assets/images/credits/zedifus.jpg" - } - ], - "support": [ - { - "name": "iSilverfyre", - "title": null, - "loc": null, - "tags": [ - "Staff", - "Wiki", - null - ], - "blurb": "Silver enjoys helping others with their computer needs, writing documentation, and loving her cat.", - "pic": "/static/assets/images/credits/isilverfyre.png" - }, - { - "name": "Quentin", - "title": null, - "loc": null, - "tags": [ - "Staff", - "Developer", - null - ], - "blurb": "Hosts Minecraft servers for his weird friends, works for an IoT company as his dayjob. The 's' in IoT stands for 'secure'.", - "pic": "/static/assets/images/credits/qub3d.png" - } - ], - "retired": [ - { - "name": "Kev Dagoat", - "title": "Head of Development", - "loc": "East Coast, AU", - "tags": [ - "Staff", - [ - "Developer", - "https://gitlab.com/kevdagoat" - ], - "HOD" - ], - "blurb": "His interests include Linux, programming, and goats of course. He enjoys building APIs, K8s, and geeking over video cards.", - "pic": "/static/assets/images/credits/kevdagoat.jpeg" - }, - { - "name": "Manu", - "title": null, - "loc": "Eastern, CA", - "tags": [ - "Staff", - "Developer", - "Project Manager" - ], - "blurb": "His interests include learning, Linux, and programming. He enjoys speaking French, doing 6 things at once, and testing software.", - "pic": "/static/assets/images/credits/manu.png" - }, - { - "name": "UltraBlack", - "title": null, - "loc": "Bavaria, DE", - "tags": [ - "Staff", - null, - "Idea Manager" - ], - "blurb": "Hi, my name is Tim, and I'm a huge fan of linux. I'm often gaming and occasionally coding.", - "pic": "/static/assets/images/credits/ultrablack.png" - } - ] + "development": [ + { + "name": "Pita Bread", + "title": "CEO", + "loc": "Midwest, USA", + "tags": [ "Staff", [ "Developer", "https://gitlab.com/craftbreadth" ], "Crafty Leadership" ], + "blurb": "Baked goods enthusiast who dabbles in Linux and sets up networks for fun. Can be found writing long-winded emails, debugging strange wifi issues, or taking care of his extensive houseplant collection.", + "pic": "/static/assets/images/credits/pita.png" + }, + { + "name": "Xithical", + "title": "CFO/COO", + "loc": "Midwest, USA", + "tags": [ "Staff", [ "Developer", "https://gitlab.com/xithical" ], "Crafty Leadership" ], + "blurb": "Having sold his soul to the IT administration gods many eons ago, you can usually find him working, going home to do work, or continuing to work in the support Discord.", + "pic": "/static/assets/images/credits/xithical.png" + }, + { + "name": "Phil Tarrant", + "title": "Creator", + "loc": "Southeast, USA", + "tags": [ "Staff", [ "Developer", "https://gitlab.com/Ptarrant1" ], "Crafty Advisor" ], + "blurb": "For best results, apply a thin layer of Phillip to code, cyber security, and parenthood for maximum effectiveness. Phillip often spends too much time with his chickens.", + "pic": "/static/assets/images/credits/ptarrant.png" + }, + { + "name": "macgeek", + "title": "Lead Software Engineer", + "loc": "Midwest, USA", + "tags": [ "Staff", [ "Developer", "https://gitlab.com/computergeek125" ], "Crafty Advisor" ], + "blurb": "Sysadmin for work and sysadmin for fun (avid homelabber). He enjoys a good tech tangent and gaming.", + "pic": "/static/assets/images/credits/macgeek.png" + }, + { + "name": "parzivaldewey", + "title": "Support Manager", + "loc": "East Coast, USA", + "tags": [ "Staff", [ "Developer", "https://gitlab.com/amcmanu3" ], "Support Manager" ], + "blurb": "His interests include Linux, gaming, and helping others. When he's able to unplug he enjoys biking, hiking, and playing soccer.", + "pic": "/static/assets/images/credits/parzivaldewey.png" + }, + { + "name": "Silversthorn", + "title": "Software Engineer", + "loc": "Provence, FR", + "tags": [ "Staff", [ "Developer", "https://gitlab.com/Silversthorn" ], null ], + "blurb": "Developer at work and at home, testing his own code is a pain, so his coding precept is \"Testing is Doubting\"", + "pic": "/static/assets/images/credits/silversthorn.png" + }, + { + "name": "ThatOneLukas", + "title": "Software Engineer", + "loc": "Helsinki, FI", + "tags": [ "Staff", [ "Developer", "https://gitlab.com/LukasDoesDev" ], null ], + "blurb": "Arch Linux enthusiast who likes 80s/90s music, Rust (The programming language) and light distros like Arch Linux btw. Dislikes C, C++, Go, and Microsoft. Also doesn't like Finnish weather that freezes molten snow to (deadly) ice.", + "pic": "/static/assets/images/credits/lukas.png" + }, + { + "name": "Zedifus", + "title": "DevOps Engineer", + "loc": "Scotland, UK", + "tags": [ "Staff", [ "Developer", "https://gitlab.com/Zedifus" ], "DevOps" ], + "blurb": "A Streamer, who is currently working in the insurance industry, self-teaching software development & DevOps, with the hopes of a career change! Enjoys playing games and has a sassy cat called Eve.", + "pic": "/static/assets/images/credits/zedifus.jpg" + } + ], + "support": [ + { + "name": "iSilverfyre", + "title": "Community Manager", + "loc": "South Central, US", + "tags": [ "Staff", null, "Wiki" ], + "blurb": "Silver enjoys helping others with their computer needs, writing documentation, and loving her cat.", + "pic": "/static/assets/images/credits/isilverfyre.png" + }, + { + "name": "Quentin", + "title": "Document Curator", + "loc": null, + "tags": [ "Staff", [ "Developer", "https://gitlab.com/qub3d" ], "Wiki" ], + "blurb": "Hosts Minecraft servers for his weird friends, works for an IoT company as his dayjob. The 's' in IoT stands for 'secure'.", + "pic": "/static/assets/images/credits/qub3d.png" + }, + { + "name": "DarthLeo", + "title": "Support Engineer", + "loc": "East Coast, US", + "tags": [ "Staff", null, "Discord" ], + "blurb": "Just a simple gamer.", + "pic": "/static/assets/images/credits/darthLeo.png" + } + ], + "retired": [ + { + "name": "Kev Dagoat", + "title": "Head of Development", + "loc": "East Coast, AU", + "tags": [ "Staff", [ "Developer", "https://gitlab.com/kevdagoat" ], "HOD" ], + "blurb": "His interests include Linux, programming, and goats of course. He enjoys building APIs, K8s, and geeking over video cards.", + "pic": "/static/assets/images/credits/kevdagoat.jpeg" + }, + { + "name": "Manu", + "title": null, + "loc": "Eastern, CA", + "tags": [ "Staff", "Developer", "Project Manager" ], + "blurb": "His interests include learning, Linux, and programming. He enjoys speaking French, doing 6 things at once, and testing software.", + "pic": "/static/assets/images/credits/manu.png" + }, + { + "name": "UltraBlack", + "title": null, + "loc": "Bavaria, DE", + "tags": [ "Staff", null, "Idea Manager" ], + "blurb": "Hi, my name is Tim, and I'm a huge fan of linux. I'm often gaming and occasionally coding.", + "pic": "/static/assets/images/credits/ultrablack.png" + }, + { + "name": "MC Gaming", + "title": null, + "loc": "Central, UK", + "tags": [ "Staff", [ "Developer", "https://gitlab.com/MCgamin1738" ], null ], + "blurb": "His interests include learning, Linux, and programming. He loves pentesting apps and gaming.", + "pic": "/static/assets/images/credits/mcgaming.png" + } + ] }, "translations": { - "ptarrant": [ - "Sarcasm", - "Wit" - ], - "Lukas": [ - "Finnish" - ], - "Manu": [ - "French" - ], - "Silversthorn": [ - "French" - ], - "UltraBlack": [ - "German" - ] + "Data Unavailable": ["🌎🪣🔗👎😭"] }, "patrons": [ { - "name": "Richard B", - "level": "Crafty Sustainer" - }, - { - "name": "JOHN C", - "level": "Crafty Advocate" - }, - { - "name": "iSilverfyre", - "level": "Crafty Sustainer" - }, - { - "name": "Dean R", - "level": "Crafty Sustainer" - }, - { - "name": "scott m", - "level": "Crafty Sustainer" - }, - { - "name": "zedifus", - "level": "Crafty Sustainer" - }, - { - "name": "Lino", - "level": "Crafty Sustainer" - }, - { - "name": "PeterPorker3", - "level": "Crafty Supporter" - }, - { - "name": "Ewari", - "level": "Crafty Supporter" - }, - { - "name": "Thyodas", - "level": "Crafty Sustainer" - }, - { - "name": "AngryPanda", - "level": "Crafty Sustainer" - }, - { - "name": "Chris T", - "level": "Crafty Sustainer" - }, - { - "name": "Adam B", - "level": "Crafty Sustainer" - }, - { - "name": "Eric G", - "level": "Crafty Sustainer" - }, - { - "name": "Chris B", - "level": "Crafty Sustainer" - }, - { - "name": "Emmet d", - "level": "Crafty Sustainer" - }, - { - "name": "Steven T", - "level": "Crafty Sustainer" - }, - { - "name": "Row H", - "level": "Crafty Supporter" + "name": "Data Unavailable 🌎🪣🔗👎😭", + "level": "" } ], - "lastUpdate": 1637281075498 + "lastUpdate": 0 } diff --git a/app/config/logging.json b/app/config/logging.json index c6ccd7b1..99de60e4 100644 --- a/app/config/logging.json +++ b/app/config/logging.json @@ -64,7 +64,7 @@ "handlers": ["tornado_access_file_handler"], "propagate": false }, - "schedule": { + "apscheduler": { "level": "INFO", "handlers": ["schedule_file_handler"], "propagate": false diff --git a/app/config/version.json b/app/config/version.json index f599a4db..c43a317c 100644 --- a/app/config/version.json +++ b/app/config/version.json @@ -2,5 +2,5 @@ "major": 4, "minor": 0, "sub": 0, - "meta": "alpha.3" + "meta": "alpha.3.5" } diff --git a/app/frontend/static/assets/css/crafty.css b/app/frontend/static/assets/css/crafty.css index c39b2c2a..f0fdda00 100644 --- a/app/frontend/static/assets/css/crafty.css +++ b/app/frontend/static/assets/css/crafty.css @@ -50,7 +50,11 @@ } .mc-log-error{ - color:#ff6258; + color:#af463f; +} + +.mc-log-fatal{ + color:#da0f00; } .mc-log-keyword{ diff --git a/app/frontend/static/assets/css/dark/style.css b/app/frontend/static/assets/css/dark/style.css index bfaa9c3f..9d272759 100755 --- a/app/frontend/static/assets/css/dark/style.css +++ b/app/frontend/static/assets/css/dark/style.css @@ -10449,10 +10449,10 @@ pre { padding-right: 80px; } .sidebar > .nav:not(.sub-menu) > .nav-item:hover:not(.nav-profile):not(.hover-open) > .nav-link:not([aria-expanded="true"]):before { border-color: #0b0b0b; } - .sidebar > .nav:not(.sub-menu) > .nav-item:hover:not(.nav-profile):not(.hover-open) > .nav-link:not([aria-expanded="true"]) .menu-title { + .sidebar > .nav:not(.sub-menu) > .nav-item:hover:not(.nav-profile):not(.hover-open) > .nav-link:not([aria-expanded="true"]) { + color: #0b0b0b; } + .sidebar > .nav:not(.sub-menu) > .nav-item:hover:not(.nav-profile):not(.hover-open) > .nav-link:not([aria-expanded="true"]) .menu-arrow::before { color: #0b0b0b; } - .sidebar > .nav:not(.sub-menu) > .nav-item:hover:not(.nav-profile):not(.hover-open) > .nav-link:not([aria-expanded="true"]) .menu-arrow:before { - color: #b9c0d3; } /* style for off-canvas menu*/ @media screen and (max-width: 991px) { diff --git a/app/frontend/static/assets/images/credits/darthLeo.png b/app/frontend/static/assets/images/credits/darthLeo.png new file mode 100644 index 00000000..09f4072f Binary files /dev/null and b/app/frontend/static/assets/images/credits/darthLeo.png differ diff --git a/app/frontend/static/assets/images/credits/user-circle-solid.svg b/app/frontend/static/assets/images/credits/user-circle-solid.svg new file mode 100644 index 00000000..5c925760 --- /dev/null +++ b/app/frontend/static/assets/images/credits/user-circle-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/static/assets/images/logo_long.svg b/app/frontend/static/assets/images/logo_long.svg new file mode 100644 index 00000000..a03323ae --- /dev/null +++ b/app/frontend/static/assets/images/logo_long.svg @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + C + RAFT + Y + + + CONTROLLER + + + + TAKE CONTROL OF YOUR SERVERS + + + + \ No newline at end of file diff --git a/app/frontend/static/assets/images/logo_small.svg b/app/frontend/static/assets/images/logo_small.svg new file mode 100644 index 00000000..668d5d0b --- /dev/null +++ b/app/frontend/static/assets/images/logo_small.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/frontend/static/assets/js/shared/misc.js b/app/frontend/static/assets/js/shared/misc.js index 5d05697f..f39bf482 100755 --- a/app/frontend/static/assets/js/shared/misc.js +++ b/app/frontend/static/assets/js/shared/misc.js @@ -207,6 +207,10 @@ if ($('canvas').length) { body.toggleClass('sidebar-hidden'); } else { body.toggleClass('sidebar-icon-only'); + const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0); + if (vw >= 1200) { + localStorage.setItem('crafty-sidebar-expanded', !body.hasClass('sidebar-icon-only')); + } } }); @@ -231,4 +235,4 @@ if ($('canvas').length) { $(this).not(".brand-logo").attr('toggle-status', 'closed'); } }); -})(jQuery); \ No newline at end of file +})(jQuery); diff --git a/app/frontend/templates/base.html b/app/frontend/templates/base.html index 964ba500..bc81718d 100644 --- a/app/frontend/templates/base.html +++ b/app/frontend/templates/base.html @@ -1,151 +1,175 @@ - - - - - - {% block meta %}{% end %} - {% block title %}{{ _('Default') }}{% end %} + - - - - - - - - - + + + + + {% block meta %}{% end %} + {% block title %}{{ _('Default') }}{% end %} - + + + + + + + + + - - - + - - - + + + + - - - + + + - -
            - - + + {% include main_menu.html %} + +
            + +
            + +
            + + {% block content %} + {% end %} + + {% include footer.html %} +
            + + +
            + + - -
            + +
            - - - - - - + + + + + + + - + document.addEventListener('alpine:init', () => { + console.log('%c[Crafty Controller] %cAlpine.js pre-initialization!', 'font-weight: 900; color: #800080;', 'color: #eee;'); + }) + document.addEventListener('alpine:initialized', () => { + console.log('%c[Crafty Controller] %cAlpine.js initialized!', 'font-weight: 900; color: #800080;', 'color: #eee;'); + }) - {% block js %} - - - {% end %} + $(document).ready(function () { + console.log('%c[Crafty Controller] %cReady for JS!', 'font-weight: 900; color: #800080;', 'font-weight: 900; color: #eee;'); + $('#support_logs').click(function () { + var dialog = bootbox.dialog({ + message: '

            {{ translate('notify', 'preparingLogs', data['lang']) }}

            ', + closeButton: false + }); + setTimeout(function () { + location.href = "/panel/support_logs"; + }, 6000); - - \ No newline at end of file + }); + }); + + + + {% block js %} + + + {% end %} + + + + diff --git a/app/frontend/templates/blank_base.html b/app/frontend/templates/blank_base.html index f4e8ec65..95893acc 100644 --- a/app/frontend/templates/blank_base.html +++ b/app/frontend/templates/blank_base.html @@ -19,7 +19,8 @@ - + +
            diff --git a/app/frontend/templates/blank_page_template.html b/app/frontend/templates/blank_page_template.html index c3ddab33..26e22ecf 100644 --- a/app/frontend/templates/blank_page_template.html +++ b/app/frontend/templates/blank_page_template.html @@ -1,7 +1,6 @@ {% extends ../base.html %} {% block meta %} - {% end %} {% block title %}Crafty Controller - Blank Page{% end %} diff --git a/app/frontend/templates/footer.html b/app/frontend/templates/footer.html index dcade283..fec76eba 100644 --- a/app/frontend/templates/footer.html +++ b/app/frontend/templates/footer.html @@ -2,10 +2,11 @@
            - {{ translate('footer', 'copyright', data['lang']) }} © 2021 Crafty Controller. {{ translate('footer', 'allRightsReserved', data['lang']) }}. - {{ translate('footer', 'version', data['lang']) }}: {{ data['version_data'] }} +{{ translate('footer', 'copyright', data['lang']) }} © 2021 - Crafty Controller. {{ translate('footer', 'allRightsReserved', data['lang']) }}. + +{{ translate('footer', 'version', data['lang']) }}: {{ data['version_data'] }}
            - \ No newline at end of file + diff --git a/app/frontend/templates/main_menu.html b/app/frontend/templates/main_menu.html index dc27b9ec..19e72cb2 100644 --- a/app/frontend/templates/main_menu.html +++ b/app/frontend/templates/main_menu.html @@ -1,6 +1,68 @@
            + + - \ No newline at end of file + diff --git a/app/frontend/templates/notify.html b/app/frontend/templates/notify.html index 46016a0b..a0182928 100644 --- a/app/frontend/templates/notify.html +++ b/app/frontend/templates/notify.html @@ -18,20 +18,25 @@
          \ No newline at end of file diff --git a/app/frontend/templates/panel/activity_logs.html b/app/frontend/templates/panel/activity_logs.html index f87c5786..c8717f38 100644 --- a/app/frontend/templates/panel/activity_logs.html +++ b/app/frontend/templates/panel/activity_logs.html @@ -1,8 +1,6 @@ {% extends ../base.html %} {% block meta %} - - {% end %} {% block title %}Crafty Controller - Activity Logs{% end %} @@ -21,40 +19,51 @@ -
          -
          +
          +
          +

           Audit Logs

          + +
          - - - - - - - - - - - - {% for row in data['audit_logs'] %} - - - - - - - - {% end %} - -
          UsernameTimeActionServer IDIP
          {{ row['user_name'] }} - {{ row['created'].strftime('%m-%d-%Y %H:%M:%S') }} - {{ row['log_msg'] }}{{ row['server_id'] }}{{ row['source_ip'] }}
          + +
          + + + + + + + + + + + + {% for row in data['audit_logs'] %} + + + + + + + + {% end %} + +
          UsernameTimeActionServer IDIP
          {{ row['user_name'] }} + {{ row['created'].strftime('%Y-%m-%d %H:%M:%S') }} + {{ row['log_msg'] }}{{ row['server_id'] }}{{ row['source_ip'] }}
          + +
          - + @@ -69,8 +78,35 @@ $( document ).ready(function() { console.log('ready for JS!') - $('#audit_table').DataTable(); + $('#audit_table').DataTable({ + 'order': [1, 'desc'] + } + ); + }); + {% end %} \ No newline at end of file diff --git a/app/frontend/templates/panel/contribute.html b/app/frontend/templates/panel/contribute.html index e7727d2f..94efdcba 100644 --- a/app/frontend/templates/panel/contribute.html +++ b/app/frontend/templates/panel/contribute.html @@ -1,7 +1,6 @@ {% extends ../base.html %} {% block meta %} - {% end %} {% block title %}Crafty Controller - Contribute{% end %} @@ -23,7 +22,7 @@
          -
          +
          @@ -31,8 +30,8 @@

          - Patrons get access to several perks, such as behind the scenes videos, posts, and updates. - Patrons also get access to a Crafty Controller PE (Patreon Edition) with additional functions not in other versions of Crafty. + Patrons get access to several perks, such as behind the scenes videos, posts, and updates.
          + Patrons also get early access to new software!


          @@ -44,35 +43,7 @@
          -
          -
          -
          - -

          One Time Support

          - -
          -

          - Contribute via Paypal, you can contribute any amount, as often or as little as you want. - Please understand that while PayPal calls this a "Donation"; this is not a charitable donation and can not be claimed - as a charitable donation for tax purposes -

          -
          -
          -
          - - - - - -
          -
          -
          - -
          -
          -
          - -
          +
          @@ -93,6 +64,28 @@
          +
          +
          +
          + +

          More Ways Coming Soon...

          + +
          +

          + Thank you for your interest in contributing to Aracdia Technology's Crafty Controller. + We are always thinking of new ways for our community to contribute to this awesome project.

          If you don't see + a contribution method that peaks your interest now please check back soon. +

          +
          +
          + +
          +
          + +
          +
          +
          +
          diff --git a/app/frontend/templates/panel/credits.html b/app/frontend/templates/panel/credits.html index faaab29b..a04147df 100644 --- a/app/frontend/templates/panel/credits.html +++ b/app/frontend/templates/panel/credits.html @@ -1,21 +1,20 @@ {% extends ../base.html %} {% block meta %} - {% end %} -{% block title %}Crafty Controller - Credits{% end %} +{% block title %}Crafty Controller - {{ translate('credits', 'pageTitle', data['lang']) }}{% end %} {% block content %}
          - +
          @@ -23,303 +22,326 @@
          -
          -
          -
          -
          -
          -

           Development Team

          -
          -
          - {% for person in data['staff']['development'] %} -
          -
          -
          - -
          -
          - -
          - profile image -
          - -
          -
          -

          {{ person['name'] }}

          -
          - -
          - {% if person['loc'] %} - -

          {{ person['loc'] }}

          - {% end %} -
          -
          -
          - -
          -
          - {% if person['tags'][0] %} - {{ person['tags'][0] }} - {% end %} - {% if person['tags'][1] %} - {% if type(person['tags'][1]) is list %} - {{ person['tags'][1][0] }} - {% else %} - {{ person['tags'][1] }} - {% end %} - {% end %} - {% if person['tags'][2] %} - {% if type(person['tags'][2]) is list %} - {{ person['tags'][2][0] }} - {% else %} - {{ person['tags'][2] }} - {% end %} - {% end %} -
          - -
          - {% if person['title'] %} -
          Crafty's {{ person['title'] }}
          - {% end %} -

          {{ person['blurb'] }}

          -
          -
          -
          - -
          -
          -
          - {% end %} - -
          - -
          -

           Support and Documentation Team

          -
          - -
          - {% for person in data['staff']['support'] %} -
          -
          -
          - -
          -
          - -
          - profile image -
          - -
          -
          -

          {{ person['name'] }}

          -
          - -
          - {% if person['loc'] %} - -

          {{ person['loc'] }}

          - {% end %} -
          -
          -
          - -
          -
          - {% if person['tags'][0] %} - {{ person['tags'][0] }} - {% end %} - {% if person['tags'][1] %} - {% if type(person['tags'][1]) is list %} - {{ person['tags'][1][0] }} - {% else %} - {{ person['tags'][1] }} - {% end %} - {% end %} - {% if person['tags'][2] %} - {% if type(person['tags'][2]) is list %} - {{ person['tags'][2][0] }} - {% else %} - {{ person['tags'][2] }} - {% end %} - {% end %} -
          - -
          - {% if person['title'] %} -
          Crafty's {{ person['title'] }}
          - {% end %} -

          {{ person['blurb'] }}

          -
          -
          -
          - -
          -
          -
          - {% end %} - -
          - -
          -

           Retired Staff

          -
          - -
          - - {% for person in data['staff']['retired'] %} -
          -
          -
          - -
          -
          - -
          - profile image -
          - -
          -
          -

          {{ person['name'] }}

          -
          - -
          - {% if person['loc'] %} - -

          {{ person['loc'] }}

          - {% end %} -
          -
          -
          - -
          -
          - {% if person['tags'][0] %} - {{ person['tags'][0] }} - {% end %} - {% if person['tags'][1] %} - {% if type(person['tags'][1]) is list %} - {{ person['tags'][1][0] }} - {% else %} - {{ person['tags'][1] }} - {% end %} - {% end %} - {% if person['tags'][2] %} - {% if type(person['tags'][2]) is list %} - {{ person['tags'][2][0] }} - {% else %} - {{ person['tags'][2] }} - {% end %} - {% end %} -
          - -
          - {% if person['title'] %} -
          Crafty's {{ person['title'] }}
          - {% end %} -

          {{ person['blurb'] }}

          -
          -
          -
          - -
          -
          -
          - {% end %} - -
          -
          -
          +
          +
          +

           {{ translate('credits', 'developmentTeam', data['lang']) }}

          -
          +
          +
          + {% for person in data['staff']['development'] %} +
          +
          -
          +
          +
          -
          -
          -
          -

          Patreon Supporters

          -

          A huge thank you  to our Patreon supporters! | Last Update: {{ data["lastUpdate"] }}

          - - - - - - - - - {% for pat in data["patrons"] %} - - - - - {% end %} + - -
          NameLevel
          {{ pat["name"] }} - {% if pat["level"] == "Crafty Sustainer" %} - Sustainer - {% elif pat["level"] == "Crafty Advocate" %} - Advocate - {% elif pat["level"] == "Crafty Supporter" %} - Supporter +
          + {% if person['pic'] %} + profile image {% else %} - Other +
          + profile image +
          {% end %} -
          -
          -
          -
          +
          +
          +

          {{ person['name'] }}

          +
          -
          -
          -
          -

          Language Translation

          -

          A huge thank you  to our community who translate!

          - - - - - - - - - {% for person in data['translations'] %} - - - - - {% end %} - -
          NameStatus
          {{ person }} - {% for language in data['translations'][person] %} - {{ language }} +
          + {% if person['loc'] %} + +

          {{ person['loc'] }}

          + {% end %} +
          + + + +
          +
          + {% if person['tags'][0] %} + {{ person['tags'][0] }} {% end %} -
          + {% if person['tags'][1] %} + {% if type(person['tags'][1]) is list %} + {{ person['tags'][1][0] }} + {% else %} + {{ person['tags'][1] }} + {% end %} + {% end %} + {% if person['tags'][2] %} + {% if type(person['tags'][2]) is list %} + {{ person['tags'][2][0] }} + {% else %} + {{ person['tags'][2] }} + {% end %} + {% end %} +
          + +
          + {% if person['title'] %} +
          Crafty's {{ person['title'] }}
          + {% end %} +

          {{ person['blurb'] }}

          +
          +
          +
          + +
          + {% end %}
          -
          - - - +
          -
          - -{% end %} +
          -{% block js %} - +
          + {% if person['pic'] %} + profile image + {% else %} +
          + profile image +
          + {% end %} +
          -{% end %} +
          +
          +

          {{ person['name'] }}

          +
          + +
          + {% if person['loc'] %} + +

          {{ person['loc'] }}

          + {% end %} +
          +
          +
          + +
          +
          + {% if person['tags'][0] %} + {{ person['tags'][0] }} + {% end %} + {% if person['tags'][1] %} + {% if type(person['tags'][1]) is list %} + {{ person['tags'][1][0] }} + {% else %} + {{ person['tags'][1] }} + {% end %} + {% end %} + {% if person['tags'][2] %} + {% if type(person['tags'][2]) is list %} + {{ person['tags'][2][0] }} + {% else %} + {{ person['tags'][2] }} + {% end %} + {% end %} +
          + +
          + {% if person['title'] %} +
          {{ person['title'] }}
          + {% end %} +

          {{ person['blurb'] }}

          +
          +
          +
          + +
          +
          + {% end %} +
          +
          +
          + +
          + +
          +
          +

           {{ translate('credits', 'retiredStaff', data['lang']) }}

          +
          + +
          +
          + {% for person in data['staff']['retired'] %} +
          +
          +
          +
          +
          + {% if person['pic'] %} + profile image + {% else %} +
          + profile image +
          + {% end %} +
          + +
          +
          +

          {{ person['name'] }}

          +
          + +
          + {% if person['loc'] %} + +

          {{ person['loc'] }}

          + {% end %} +
          +
          +
          + +
          +
          + {% if person['tags'][0] %} + {{ person['tags'][0] }} + {% end %} + {% if person['tags'][1] %} + {% if type(person['tags'][1]) is list %} + {{ person['tags'][1][0] }} + {% else %} + {{ person['tags'][1] }} + {% end %} + {% end %} + {% if person['tags'][2] %} + {% if type(person['tags'][2]) is list %} + {{ person['tags'][2][0] }} + {% else %} + {{ person['tags'][2] }} + {% end %} + {% end %} +
          + +
          + {% if person['title'] %} +
          {{ person['title'] }}
          + {% end %} +

          {{ person['blurb'] }}

          +
          +
          +
          + +
          +
          + {% end %} +
          +
          +
          + +
          + +
          + +
          +
          +
          +

          {{ translate('credits', 'patreonSupporter', + data['lang']) + }}

          +
          +
          +

          {{ translate('credits', 'hugeDesc', data['lang']) }} + {{ translate('credits', 'thankYou', data['lang']) }}  {{ translate('credits', 'patreonDesc', data['lang']) }} | {{ translate('credits', 'patreonUpdate', data['lang']) }} {{ data["lastUpdate"] }} +

          + + + + + + + + + {% for pat in data["patrons"] %} + + + + + {% end %} + + +
          {{ translate('credits', 'patreonName', data['lang']) }}{{ translate('credits', 'patreonLevel', data['lang']) }}
          {{ pat["name"] }} + {% if pat["level"] == "Crafty Sustainer" %} + Sustainer + {% elif pat["level"] == "Crafty Advocate" %} + Advocate + {% elif pat["level"] == "Crafty Supporter" %} + Supporter + {% else %} + {{ translate('credits', 'patreonOther', data['lang']) }} + {% end %} +
          +
          +
          +
          + +
          +
          +
          +

          {{ translate('credits', 'translationTitle', data['lang']) }}

          +
          +
          +

          {{ translate('credits', 'hugeDesc', data['lang']) }} + {{ translate('credits', 'thankYou', data['lang']) }}  {{ translate('credits', 'translationDesc', data['lang']) }} +

          + + + + + + + + + {% for person in data['translations'] %} + + + + + {% end %} + +
          {{ translate('credits', 'translationName', data['lang']) }}{{ translate('credits', 'translator', data['lang']) }}
          {{ person }} +
          + {% for language in data['translations'][person] %} + {{ language }} + {% end %} +
          +
          +
          +
          +
          + +
          +
          + + + {% end %} + + {% block js %} + + + {% end %} \ No newline at end of file diff --git a/app/frontend/templates/panel/dashboard.html b/app/frontend/templates/panel/dashboard.html index 96396a22..40d0ce13 100644 --- a/app/frontend/templates/panel/dashboard.html +++ b/app/frontend/templates/panel/dashboard.html @@ -1,7 +1,6 @@ {% extends ../base.html %} {% block meta %} - {% end %} {% block title %}Crafty Controller - {{ translate('dashboard', 'dashboard', data['lang']) }}{% end %} @@ -10,11 +9,14 @@
          - +
          @@ -29,16 +31,21 @@
          -
          {{ translate('dashboard', 'host', data['lang']) }}
          +
          {{ translate('dashboard', 'host', data['lang']) }} +

          -

          - {{ translate('dashboard', 'cpuUsage', data['lang']) }}: {{ data.get('hosts_data').get('cpu_usage') }} +

          + {{ translate('dashboard', 'cpuUsage', data['lang']) }}: {{ + data.get('hosts_data').get('cpu_usage') }}

          -

          - {{ translate('dashboard', 'memUsage', data['lang']) }}: {{ data.get('hosts_data').get('mem_percent') }}% +

          + {{ translate('dashboard', 'memUsage', data['lang']) }}: {{ + data.get('hosts_data').get('mem_percent') }}%

          @@ -46,26 +53,28 @@
          -
          {{ translate('dashboard', 'servers', data['lang']) }}
          +
          {{ translate('dashboard', 'servers', data['lang']) }} +

          {{ data['server_stats']['total'] }}

          -

          {{ data['server_stats']['running'] }} {{ translate('dashboard', 'online', data['lang']).lower() }}

          -

          {{ data['server_stats']['stopped'] }} {{ translate('dashboard', 'offline', data['lang']).lower() }}

          +

          {{ data['server_stats']['running'] }} {{ translate('dashboard', 'online', + data['lang']).lower() }}

          +

          {{ data['server_stats']['stopped'] }} {{ translate('dashboard', + 'offline', data['lang']).lower() }}

          -
          {{ translate('dashboard', 'players', data['lang']) }}
          -

          {{ data['num_players'] }}

          - +
          {{ translate('dashboard', 'players', data['lang']) }} +
          +

          {{ data['num_players'] }}

          -

          35 {{ translate('dashboard', 'max', data['lang']) }}

          -

          10 {{ translate('dashboard', 'avg', data['lang']) }}

          +

          0 {{ translate('dashboard', 'max', data['lang']) }}

          @@ -79,24 +88,29 @@
          -

           {{ translate('dashboard', 'allServers', data['lang']) }}

          +

           {{ translate('dashboard', 'allServers', + data['lang']) }}

          {% if len(data['servers']) > 0 %} - + {% end %} - +
          - {% if len(data['servers']) == 0%} -
          -

          {{ translate('dashboard', 'welcome', data['lang']) }}

          -
          - {{ translate('dashboard', 'no-servers', data['lang']) }} {{ translate('dashboard', 'newServer', data['lang']) }}. -
          + {% if len(data['servers']) == 0%} +
          +

          {{ translate('dashboard', 'welcome', data['lang']) }}

          +
          + {{ translate('dashboard', 'no-servers', data['lang']) }} {{ translate('dashboard', 'newServer', + data['lang']) }}. +
          - {% end %} - {% if len(data['servers']) > 0 %} + {% end %} + {% if len(data['servers']) > 0 %} @@ -104,41 +118,71 @@ - + {% for server in data['servers'] %} - - + - - - - - + {% end %} @@ -212,197 +266,375 @@ {% end %} {% block js %} + + -{% end %} \ No newline at end of file +{% end %} diff --git a/app/frontend/templates/panel/denied.html b/app/frontend/templates/panel/denied.html index 2ba006f0..87f5bafc 100644 --- a/app/frontend/templates/panel/denied.html +++ b/app/frontend/templates/panel/denied.html @@ -17,7 +17,8 @@ - + +
          @@ -28,7 +29,7 @@
          -

          +

          {{ translate('dashboard', 'actions', data['lang']) }} {{ translate('dashboard', 'cpuUsage', data['lang']) }} {{ translate('dashboard', 'memUsage', data['lang']) }}{{ translate('dashboard', 'world', data['lang']) }}{{ translate('dashboard', 'size', data['lang']) }} {{ translate('dashboard', 'players', data['lang']) }} {{ translate('dashboard', 'status', data['lang']) }}
          +
          - + {{ server['server_data']['server_name'] }} {% if server['user_command_permission'] %} - {% if server['stats']['running'] %} -   -   -   - {% elif server['stats']['updating']%} - {{ translate('serverTerm', 'updating', data['lang']) }} - {% elif server['stats']['waiting_start']%} - {{ translate('dashboard', 'starting', data['lang']) }} - {% else %} -   -   -   - {% end %} + {% if server['stats']['running'] %} + + +   + + + +   + + + +   + + {% elif server['stats']['updating']%} + + {{ translate('serverTerm', 'updating', + data['lang']) }} + {% elif server['stats']['waiting_start']%} + + {{ translate('dashboard', 'starting', + data['lang']) }} + {% elif server['stats']['downloading']%} + {{ translate('serverTerm', 'downloading', + data['lang']) }} + {% else %} + + +   + + +   + + +   + {% end %} {% end %} -
          +
          +
          + " role="progressbar" style="width: {{server['stats']['cpu']}}%" aria-valuenow="0" + aria-valuemin="0" aria-valuemax="100">
          {{server['stats']['cpu']}}%
          -
          +
          +
          + " role="progressbar" style="width: {{server['stats']['mem_percent']}}%" aria-valuenow="0" + aria-valuemin="0" aria-valuemax="100">
          {{server['stats']['mem_percent']}}% - {% if server['stats']['mem'] == 0 %} - 0 MB + 0 MB {% else %} - {{server['stats']['mem']}} + {{server['stats']['mem']}} {% end %}
          - {{ server['stats']['world_name'] }} : {{ server['stats']['world_size'] }} + + {{ server['stats']['world_size'] }} + {% if server['stats']['int_ping_results'] %} - {{ server['stats']['online'] }} / {{ server['stats']['max'] }} {{ translate('dashboard', 'max', data['lang']) }}
          + {{ server['stats']['online'] }} / {{ server['stats']['max'] }} {{ translate('dashboard', 'max', + data['lang']) }}
          - {% if server['stats']['desc'] != 'False' %} - {{ server['stats']['desc'] }}
          - {% end %} + {% if server['stats']['desc'] != 'False' %} + {{ server['stats']['desc'] }}
          + {% end %} - {% if server['stats']['version'] != 'False' %} - {{ server['stats']['version'] }} - {% end %} + {% if server['stats']['version'] != 'False' %} + {{ server['stats']['version'] }} + {% end %} {% end %}
          + {% if server['stats']['running'] %} - {{ translate('dashboard', 'online', data['lang']) }} + {{ translate('dashboard', 'online', + data['lang']) }} + {% elif server['stats']['crashed'] %} + {{ translate('dashboard', 'crashed', + data['lang']) }} {% else %} - {{ translate('dashboard', 'offline', data['lang']) }} + {{ translate('dashboard', 'offline', + data['lang']) }} {% end %}
          + - - - - - - + + + + + @@ -64,9 +65,6 @@ {% end %} -
          UserEnabledAPI TokenAllowed ServersAssigned RolesEdit{{ translate('panelConfig', 'user', data['lang']) }}{{ translate('panelConfig', 'enabled', data['lang']) }}{{ translate('panelConfig', 'allowedServers', data['lang']) }}{{ translate('panelConfig', 'assignedRoles', data['lang']) }}{{ translate('panelConfig', 'edit', data['lang']) }}
          - -
            {% for item in data['auth-servers'][user.user_id] %} @@ -95,19 +93,20 @@
            -

            Roles

            +

            {{ translate('panelConfig', 'roles', data['lang']) }}

            - +
            + - - - - + + + + @@ -142,6 +141,21 @@ + {% if data['superuser'] %} +
            +
            +
            +
            +

            {{ translate('panelConfig', 'adminControls', data['lang']) }}

            +
            +
            + + +
            +
            +
            +
            + {% end %} @@ -207,6 +221,17 @@ $( document ).ready(function() { message: api_key, }); }); + + $('.clear-comm').click(function () { + var token = getCookie("_xsrf") + $.ajax({ + type: "POST", + headers: {'X-XSRFToken': token}, + url: '/ajax/clear_comm', + success: function (data) { + }, + }); +}) {% end %} \ No newline at end of file diff --git a/app/frontend/templates/panel/panel_edit_role.html b/app/frontend/templates/panel/panel_edit_role.html index bdb7ed65..e97e081f 100644 --- a/app/frontend/templates/panel/panel_edit_role.html +++ b/app/frontend/templates/panel/panel_edit_role.html @@ -1,10 +1,9 @@ {% extends ../base.html %} {% block meta %} - {% end %} -{% block title %}Crafty Controller - Edit Role{% end %} +{% block title %}Crafty Controller - {{ translate('rolesConfig', 'pageTitle', data['lang']) }}{% end %} {% block content %} @@ -16,13 +15,13 @@
            RoleAllowed ServersRole UsersEdit{{ translate('panelConfig', 'role', data['lang']) }}{{ translate('panelConfig', 'allowedServers', data['lang']) }}{{ translate('panelConfig', 'roleUsers', data['lang']) }}{{ translate('panelConfig', 'edit', data['lang']) }}
            - - + + @@ -130,7 +137,7 @@
            -

            Crafty Permissions - permissions this user has on Crafty Controller

            +

            {{ translate('userConfig', 'craftyPerms', data['lang']) }} - {{ translate('userConfig', 'craftyPermDesc', data['lang']) }}

            @@ -138,9 +145,9 @@
            Role NameMember?{{ translate('userConfig', 'roleName', data['lang']) }}{{ translate('userConfig', 'member', data['lang']) }}
            - - - + + + @@ -167,25 +174,17 @@
            - - @@ -199,19 +198,17 @@
            -

            User Config Area

            -

            Here is where you can change the configuration of your user

            +

            {{ translate('userConfig', 'configArea', data['lang']) }}

            +

            {{ translate('userConfig', 'configAreaDesc', data['lang']) }}

            - Created: {{ str(data['user']['created']) }} + {{ translate('userConfig', 'created', data['lang']) }} {{ str(data['user']['created']) }}
            - Last login: {{ str(data['user']['last_login']) }} + {{ translate('userConfig', 'lastLogin', data['lang']) }} {{ str(data['user']['last_login']) }}
            - Last update: {{ str(data['user']['last_update']) }} + {{ translate('userConfig', 'lastUpdate', data['lang']) }} {{ str(data['user']['last_update']) }}
            - Last IP: {{ data['user']['last_ip'] }} -
            - API Key: {{ data['user']['api_token'] }} + {{ translate('userConfig', 'lastIP', data['lang']) }} {{ data['user']['last_ip'] }}

            @@ -219,13 +216,13 @@
            {% if data['new_user'] %} - Delete User
            - You cannot delete something that does not yet exist + {{ translate('userConfig', 'deleteUserB', data['lang']) }}
            + {{ translate('userConfig', 'notExist', data['lang']) }} {% elif data['user']['superuser'] %} - Delete User
            - You cannot delete a superuser + {{ translate('userConfig', 'deleteUserB', data['lang']) }}
            + {{ translate('userConfig', 'delSuper', data['lang']) }} {% else %} - Delete User +
            @@ -246,6 +243,60 @@ {% block js %} + +{% end %} diff --git a/app/frontend/templates/panel/parts/details_stats.html b/app/frontend/templates/panel/parts/details_stats.html index 51a57b9a..aa199340 100644 --- a/app/frontend/templates/panel/parts/details_stats.html +++ b/app/frontend/templates/panel/parts/details_stats.html @@ -1,54 +1,58 @@
            -
            -
            -
            -
            -
            - {% if data['server_stats']['running'] %} - {{ translate('serverStats', 'serverStatus', data['lang']) }}: {{ translate('serverStats', 'online', data['lang']) }}
            - {{ translate('serverStats', 'serverStarted', data['lang']) }}: {{ data['server_stats']['started'] }} ({{ translate('serverStats', 'serverTime', data['lang']) }})
            - {{ translate('serverStats', 'serverUptime', data['lang']) }}: {{ translate('serverStats', 'errorCalculatingUptime', data['lang']) }} - {% else %} - {{ translate('serverStats', 'serverStatus', data['lang']) }}: {{ translate('serverStats', 'offline', data['lang']) }}
            - {{ translate('serverStats', 'serverStarted', data['lang']) }}: {{ translate('serverStats', 'offline', data['lang']) }}
            - {{ translate('serverStats', 'serverUptime', data['lang']) }}: {{ translate('serverStats', 'offline', data['lang']) }} - {% end %} -
            - -
            - {{ translate('serverStats', 'cpuUsage', data['lang']) }}: {{ data['server_stats']['cpu'] }}%
            - {{ translate('serverStats', 'memUsage', data['lang']) }}: {{ data['server_stats']['mem'] }}
            - {% if data['server_stats']['int_ping_results'] %} - {{ translate('serverStats', 'players', data['lang']) }}: {{ data['server_stats']['online'] }} / {{ data['server_stats']['max'] }}
            - {% else %} - {{ translate('serverStats', 'players', data['lang']) }}: 0/0
            - {% end %} -
            - -
            - {% if data['server_stats']['version'] != 'False' %} - {{ translate('serverStats', 'version', data['lang']) }}: {{ data['server_stats']['version'] }}
            - {{ translate('serverStats', 'description', data['lang']) }}: {{ data['server_stats']['desc'] }}
            - {% else %} - {{ translate('serverStats', 'version', data['lang']) }}: {{ translate('serverStats', 'unableToConnect', data['lang']) }}
            - {{ translate('serverStats', 'description', data['lang']) }}: {{ translate('serverStats', 'unableToConnect', data['lang']) }}
            - {% end %} -
            - -
            +
            +
            +
            +
            +
            + {% if data['server_stats']['running'] %} + {{ translate('serverStats', 'serverStatus', data['lang']) }}: {{ translate('serverStats', 'online', data['lang']) }}
            + {{ translate('serverStats', 'serverStarted', data['lang']) }}: {{ data['server_stats']['started'] }}
            + {{ translate('serverStats', 'serverUptime', data['lang']) }}: {{ translate('serverStats', 'errorCalculatingUptime', data['lang']) }} + {% elif data['server_stats']['crashed'] %} + {{ translate('serverStats', 'serverStatus', data['lang']) }}: {{ translate('dashboard', 'crashed', data['lang']) }}
            + {{ translate('serverStats', 'serverStarted', data['lang']) }}: {{ translate('dashboard', 'crashed', data['lang']) }}
            + {{ translate('serverStats', 'serverUptime', data['lang']) }}: {{ translate('dashboard', 'crashed', data['lang']) }} + {% else %} + {{ translate('serverStats', 'serverStatus', data['lang']) }}: {{ translate('serverStats', 'offline', data['lang']) }}
            + {{ translate('serverStats', 'serverStarted', data['lang']) }}: {{ translate('serverStats', 'offline', data['lang']) }}
            + {{ translate('serverStats', 'serverUptime', data['lang']) }}: {{ translate('serverStats', 'offline', data['lang']) }} + {% end %} +
            + {{ translate('serverStats', 'serverTimeZone', data['lang']) }}: {{ data['serverTZ'] }}
            +
            + {{ translate('serverStats', 'cpuUsage', data['lang']) }}: {{ data['server_stats']['cpu'] }}%
            + {{ translate('serverStats', 'memUsage', data['lang']) }}: {{ data['server_stats']['mem'] }}
            + {% if data['server_stats']['int_ping_results'] %} + {{ translate('serverStats', 'players', data['lang']) }}: {{ data['server_stats']['online'] }} / {{ data['server_stats']['max'] }}
            + {% else %} + {{ translate('serverStats', 'players', data['lang']) }}: 0/0
            + {% end %} +
            + +
            + {% if data['server_stats']['version'] != 'False' %} + {{ translate('serverStats', 'version', data['lang']) }}: {{ data['server_stats']['version'] }}
            + {{ translate('serverStats', 'description', data['lang']) }}: {{ data['server_stats']['desc'] }}
            + {% else %} + {{ translate('serverStats', 'version', data['lang']) }}: {{ translate('serverStats', 'unableToConnect', data['lang']) }}
            + {{ translate('serverStats', 'description', data['lang']) }}: {{ translate('serverStats', 'unableToConnect', data['lang']) }}
            + {% end %} + Server Type: {{data['server_stats']['server_type']}} + +
            - +
            +
            + - \ No newline at end of file + + function update_server_details(server) { + server_status = document.getElementById('status'); + server_started = document.getElementById('started'); + server_uptime = document.getElementById('uptime'); + server_cpu = document.getElementById('cpu'); + server_mem = document.getElementById('mem'); + server_players = document.getElementById('players'); + server_version = document.getElementById('version'); + server_input_motd = document.getElementById('input_motd'); + + /* TODO Update each element */ + if (server.running){ + server_status.setAttribute("class", "text-success"); + server_status.innerHTML = `{{ translate('serverStats', 'online', data['lang']) }}`; + + startedUTC = server.started; + startedUTC = moment.utc(startedUTC, 'YYYY-MM-DD HH:mm:ss'); + var browserUTCOffset = moment().utcOffset(); // This is in minutes + startedLocal = startedUTC.utcOffset(browserUTCOffset); + startedLocalFormatted = startedLocal.format('YYYY-MM-DD HH:mm:ss'); + server_started.setAttribute("class", ""); + server_started.innerHTML = startedLocalFormatted; + server_uptime.setAttribute("class", ""); + if (!uptimeLoop) { + var calculateUptime = () => { + var msdiff = moment().diff(startedLocal); + var diff = moment.duration(msdiff); + uptime.textContent = durationToHumanizedString(diff); + } + uptimeLoop = setInterval(calculateUptime, 1000); + } + } + else + { + if (server.crashed){ + server_status.setAttribute("class", "text-danger"); + server_status.innerHTML = ` {{ translate('dashboard', 'crashed', data['lang']) }}`; + server_started.setAttribute("class", "text-danger"); + server_started.innerHTML = ` {{ translate('dashboard', 'crashed', data['lang']) }}`; + clearInterval(uptimeLoop); + uptimeLoop = null; + server_uptime.setAttribute("class", "text-danger"); + server_uptime.innerHTML = ` {{ translate('dashboard', 'crashed', data['lang']) }}`; + }else{ + server_status.setAttribute("class", "text-warning"); + server_status.innerHTML = `{{ translate('serverStats', 'offline', data['lang']) }}`; + server_started.setAttribute("class", "text-warning"); + server_started.innerHTML = `{{ translate('serverStats', 'offline', data['lang']) }}`; + clearInterval(uptimeLoop); + uptimeLoop = null; + server_uptime.setAttribute("class", "text-warning"); + server_uptime.innerHTML = `{{ translate('serverStats', 'offline', data['lang']) }}`; + } + } + + server_cpu.innerHTML = server.cpu + ` %`; + server_mem.innerHTML = server.mem; + + if (server.int_ping_results) + { + server_players.innerHTML = server.online + `/` + server.max; + } + else + { + server_players.innerHTML = `0/0`; + } + + if (server.version) + { + server_version.innerHTML = server.version; + server_input_motd.innerHTML = server.desc; + } + else + { + server_version.innerHTML = `{{ translate('serverStats', 'unableToConnect', data['lang']) }}`; + server_input_motd.innerHTML = `{{ translate('serverStats', 'unableToConnect', data['lang']) }}`; + } + + initParser('input_motd', 'input_motd'); + + } + + $(window).ready(function () { + console.log("ready!"); + + //if (webSocket) { + webSocket.on('update_server_details', update_server_details); + //} + }); + diff --git a/app/frontend/templates/panel/parts/server_controls_list.html b/app/frontend/templates/panel/parts/server_controls_list.html index b3502e96..a232f022 100644 --- a/app/frontend/templates/panel/parts/server_controls_list.html +++ b/app/frontend/templates/panel/parts/server_controls_list.html @@ -5,7 +5,8 @@ {{ translate('serverDetails', 'terminal', data['lang']) }} {% end %} - {% if data['permissions']['Logs'] in data['user_permissions'] %} + + {% if data['permissions']['Logs'] in data['user_permissions'] and data['server_data']['type'] != 'minecraft-bedrock'%} {% end %} @@ -35,7 +36,7 @@ {{ translate('serverDetails', 'config', data['lang']) }} {% end %} - {% if data['permissions']['Players'] in data['user_permissions'] %} + {% if data['permissions']['Players'] in data['user_permissions'] and data['server_data']['type'] != 'minecraft-bedrock' %}
            Permission NameAuthorized ?Quantity{{ translate('userConfig', 'permName', data['lang']) }}{{ translate('userConfig', 'auth', data['lang']) }}{{ translate('userConfig', 'uses', data['lang']) }}
            + + + + + + + + + + + + + {% for schedule in data['schedules'] %} + + + + + + + + + + {% end %} + +
            IDActionCommandIntervalStart TimeEnabledEdit
            +

            {{schedule.schedule_id}}

            +
            +

            {{schedule.action}}

            +
            +

            {{schedule.command}}

            +
            + {% if schedule.interval != '' %} +

            Every

            +

            {{schedule.interval}} {{schedule.interval_type}}

            + {% elif schedule.interval_type == 'reaction' %} +

            {{schedule.interval_type}}

            child of ID: {{ schedule.parent }}

            + {% else %} +

            Cron String:

            +

            {{schedule.cron_string}}

            + {% end %} +
            +

            {{schedule.start_time}}

            +
            + {% if schedule.enabled %} + + Yes + + {% else %} + + No + + {% end %} + + +
            +
            + +
            +
            + + + + + + + + + + {% for schedule in data['schedules'] %} + + + + + + + + {% end %} + +
            ActionCommandEnabled
            +

            {{schedule.action}}

            +
            +

            {{schedule.command}}

            +
            + {% if schedule.enabled %} + + Yes + + {% else %} + + No + + {% end %} +
            +
            +
            +
            +
            + + + + + + + + + + + + + +{% end %} + +{% block js %} + + + +{% end %} diff --git a/app/frontend/templates/panel/server_term.html b/app/frontend/templates/panel/server_term.html index 70c8b44a..0949cd9f 100644 --- a/app/frontend/templates/panel/server_term.html +++ b/app/frontend/templates/panel/server_term.html @@ -1,7 +1,6 @@ {% extends ../base.html %} {% block meta %} - {% end %} {% block title %}Crafty Controller - {{ translate('serverDetails', 'serverDetails', data['lang']) }}{% end %} @@ -10,14 +9,14 @@
            - +
            @@ -25,67 +24,74 @@
            - {% include "parts/details_stats.html %} + {% include "parts/details_stats.html %}
            - {% include "parts/server_controls_list.html %} - -
            -
            -
            -
            -
            + {% include "parts/server_controls_list.html %} -
            - - - - - -
            - {% if data['permissions']['Commands'] in data['user_permissions'] %} - {% if data['server_stats']['updating']%} -
            - - - -
            - {% elif data['waiting_start'] %} -
            - - - -
            - {% else %} -
            - - - -
            - {% end %} - {% end %} +
            +
            +
            +
            +
            + + + + +
            + {% if data['permissions']['Commands'] in data['user_permissions'] %} + {% if data['server_stats']['updating']%} +
            + + + +
            + {% elif data['waiting_start'] %} +
            + + + +
            + {% elif data['downloading'] %} +
            + + + +
            + {% else %} +
            + + + +
            + {% end %} + {% end %} +
            @@ -94,156 +100,192 @@ {% block js %} -{% end %} \ No newline at end of file +{% end %} diff --git a/app/frontend/templates/public/404.html b/app/frontend/templates/public/404.html index 13276305..c62258ad 100644 --- a/app/frontend/templates/public/404.html +++ b/app/frontend/templates/public/404.html @@ -17,7 +17,8 @@ - + +
            @@ -28,7 +29,7 @@
            -

            +