TDD: 1, Me: 0
This commit is contained in:
18
app/auth.py
Normal file
18
app/auth.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from flask_httpauth import HTTPBasicAuth
|
||||
from werkzeug.security import check_password_hash
|
||||
from flask import current_app as app # Using the current Flask app context
|
||||
|
||||
auth = HTTPBasicAuth()
|
||||
|
||||
# Initialize the users dictionary once
|
||||
users = {}
|
||||
|
||||
@auth.verify_password
|
||||
def verify_password(username, password):
|
||||
global users # Ensure we're modifying the global users dictionary
|
||||
if not users: # Only populate the dictionary if it's empty
|
||||
users = {
|
||||
"anon": app.config['USER_PASS_HASH']
|
||||
}
|
||||
if username in users and check_password_hash(users.get(username), password):
|
||||
return username
|
||||
137
app/config.py
Normal file
137
app/config.py
Normal file
@@ -0,0 +1,137 @@
|
||||
import os
|
||||
import re
|
||||
from dotenv import load_dotenv
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
# Create an empty .env file if it doesn't exist
|
||||
env_file = '.env'
|
||||
if not os.path.exists(env_file):
|
||||
with open(env_file, 'w') as file:
|
||||
file.write('# Environment variables\n')
|
||||
|
||||
# Function to manually read and parse the .env file for duplicates
|
||||
def read_env_file(file_path):
|
||||
with open(file_path, 'r') as file:
|
||||
lines = file.readlines()
|
||||
|
||||
exact_mac_names_seen = set()
|
||||
exact_iface_names_seen = set()
|
||||
errors = []
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
|
||||
key, value = line.split('=', 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
|
||||
if key.endswith('_MAC'):
|
||||
if key in exact_mac_names_seen:
|
||||
errors.append(f"Exact duplicate MAC entry: {key}")
|
||||
exact_mac_names_seen.add(key)
|
||||
elif key.endswith('_IFACE'):
|
||||
if key in exact_iface_names_seen:
|
||||
errors.append(f"Exact duplicate IFACE entry: {key}")
|
||||
exact_iface_names_seen.add(key)
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(error)
|
||||
raise SystemExit(1)
|
||||
|
||||
# Manually read and parse the .env file
|
||||
read_env_file(env_file)
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
def validate_mac_address(mac):
|
||||
# Regular expression pattern for a MAC address
|
||||
mac_pattern = re.compile(r'^(?:[0-9A-Fa-f]{2}[:-]){5}(?:[0-9A-Fa-f]{2})$')
|
||||
return mac_pattern.match(mac)
|
||||
|
||||
def validate_network_interface(iface):
|
||||
# Regular expression pattern for a network interface
|
||||
iface_pattern = re.compile(r'^(?:eth|wlan|eno|en)\d+$')
|
||||
return iface_pattern.match(iface)
|
||||
|
||||
def validate_ip_or_domain(address):
|
||||
# Regular expression pattern for an IP address or domain name
|
||||
ip_domain_pattern = re.compile(r'^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$|^[a-zA-Z0-9-]{1,63}(?:\.[a-zA-Z]{2,})+$')
|
||||
if ip_domain_pattern.match(address):
|
||||
# If it's an IP address, verify that each octet is within the correct range
|
||||
if re.match(r'^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$', address):
|
||||
octets = address.split('.')
|
||||
for octet in octets:
|
||||
if int(octet) < 0 or int(octet) > 255:
|
||||
return False
|
||||
return True
|
||||
return False
|
||||
|
||||
def validate_environment_variables():
|
||||
errors = []
|
||||
mac_names_seen = {}
|
||||
iface_names_seen = {}
|
||||
|
||||
# List of required MAC address environment variables, interfaces and ips
|
||||
mac_vars = {var: os.getenv(var) for var in os.environ if var.endswith('_MAC')}
|
||||
iface_vars = {var: os.getenv(var) for var in os.environ if var.endswith('_IFACE')}
|
||||
ip_vars = {var: os.getenv(var) for var in os.environ if var.endswith('_IP')}
|
||||
|
||||
# Check for duplicate MAC names (case-insensitive)
|
||||
for var in mac_vars:
|
||||
name_lower = var.rsplit('_', 1)[0].lower()
|
||||
if name_lower in mac_names_seen:
|
||||
errors.append(f"Duplicate MAC entries: {mac_names_seen[name_lower]} and {var}")
|
||||
mac_names_seen[name_lower] = var
|
||||
|
||||
# Check for duplicate interface names (case-insensitive)
|
||||
for var in iface_vars:
|
||||
name_lower = var.rsplit('_', 1)[0].lower()
|
||||
if name_lower in iface_names_seen:
|
||||
errors.append(f"Duplicate IFACE entries: {iface_names_seen[name_lower]} and {var}")
|
||||
iface_names_seen[name_lower] = var
|
||||
|
||||
# Validate MAC addresses (required)
|
||||
for var, mac in mac_vars.items():
|
||||
if not mac:
|
||||
errors.append(f"Missing environment variable: {var}")
|
||||
elif not validate_mac_address(mac):
|
||||
errors.append(f"Invalid MAC address format: {var} = {mac}")
|
||||
if not mac_vars:
|
||||
errors.append("No MAC addresses found in environment variables.")
|
||||
|
||||
# Validate network interfaces (optional)
|
||||
for var, iface in iface_vars.items():
|
||||
if iface == "":
|
||||
errors.append(f"Empty interface variable: {var}")
|
||||
elif iface and not validate_network_interface(iface):
|
||||
errors.append(f"Invalid network interface format: {var} = {iface}")
|
||||
|
||||
# Validate IP addresses or domain names (optional)
|
||||
for var, address in ip_vars.items():
|
||||
if address == "":
|
||||
errors.append(f"Empty IP address or domain name variable: {var}")
|
||||
elif address and not validate_ip_or_domain(address):
|
||||
errors.append(f"Invalid IP address or domain name format: {var} = {address}")
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(error)
|
||||
raise SystemExit(1)
|
||||
|
||||
# Validate environment variables
|
||||
validate_environment_variables()
|
||||
|
||||
# Centralized configuration
|
||||
class Config:
|
||||
SECRET_KEY = os.getenv('WOL_SECRET_KEY', '28c93e98b2a87e47db16372bdb6e7593fb1addf9ccc10eae562827a7358cab3b')
|
||||
USER_PASS = os.getenv('WOL_USER_PASS', '4acG2wHmp1-B')
|
||||
USER_PASS_HASH = generate_password_hash(USER_PASS, method='pbkdf2:sha256', salt_length=16) # Hash the password once and store it
|
||||
REDIS_URL = os.getenv('REDIS_URL', 'redis://localhost:6379/0')
|
||||
RATE_LIMITS = ["200 per day", "50 per hour"]
|
||||
LOG_FILE = 'app.log'
|
||||
LOG_MAX_BYTES = 10000
|
||||
LOG_BACKUP_COUNT = 1
|
||||
14
app/limiter_config.py
Normal file
14
app/limiter_config.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
|
||||
def setup_limiter(app):
|
||||
|
||||
# Rate limiter setup
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
app=app,
|
||||
storage_uri=app.config['REDIS_URL'],
|
||||
default_limits=app.config['RATE_LIMITS']
|
||||
)
|
||||
|
||||
return limiter
|
||||
26
app/logging_config.py
Normal file
26
app/logging_config.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from datetime import datetime
|
||||
|
||||
# Custom formatter class
|
||||
class CustomFormatter(logging.Formatter):
|
||||
def formatTime(self, record, datefmt=None):
|
||||
record_time = datetime.fromtimestamp(record.created)
|
||||
return record_time.strftime('%d-%m-%Y %H:%M:%S') + f".{record_time.microsecond // 1000:03}"
|
||||
|
||||
# Enhanced logging configuration with rotating file handler
|
||||
def setup_logging(app):
|
||||
logHandler = RotatingFileHandler(app.config['LOG_FILE'], maxBytes=app.config['LOG_MAX_BYTES'], backupCount=app.config['LOG_BACKUP_COUNT'])
|
||||
logHandler.setLevel(logging.INFO)
|
||||
formatter = CustomFormatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
logHandler.setFormatter(formatter)
|
||||
app.logger.addHandler(logHandler)
|
||||
|
||||
# Add a basic console handler (optional)
|
||||
consoleHandler = logging.StreamHandler()
|
||||
consoleHandler.setLevel(logging.INFO)
|
||||
consoleHandler.setFormatter(formatter)
|
||||
app.logger.addHandler(consoleHandler)
|
||||
|
||||
# Set the application's logger to use the configured handlers
|
||||
app.logger.setLevel(logging.INFO)
|
||||
14
app/redis_config.py
Normal file
14
app/redis_config.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from redis import from_url, ConnectionPool, Redis
|
||||
|
||||
# Set up Redis connection for Flask-Limiter
|
||||
def setup_redis(app):
|
||||
try:
|
||||
pool = ConnectionPool.from_url(app.config['REDIS_URL'])
|
||||
redis_connection = Redis(connection_pool=pool)
|
||||
redis_connection.ping()
|
||||
app.logger.info("Connected to Redis")
|
||||
# Return the redis_connection to be used elsewhere
|
||||
return redis_connection
|
||||
except Exception as e:
|
||||
app.logger.error(f"Failed to connect to Redis: {str(e)}")
|
||||
return None
|
||||
132
app/routes.py
Normal file
132
app/routes.py
Normal file
@@ -0,0 +1,132 @@
|
||||
from flask import request, jsonify
|
||||
from redis_config import setup_redis
|
||||
import subprocess
|
||||
from auth import auth # Importing authentication setup
|
||||
from wol_utils import send_magic_packet # Importing the utility function
|
||||
import os
|
||||
|
||||
def setup_routes(limiter, app, redis_connection):
|
||||
|
||||
# Security headers
|
||||
@app.after_request
|
||||
def add_security_headers(response):
|
||||
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
response.headers['X-Frame-Options'] = 'DENY'
|
||||
#response.headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self';"
|
||||
response.headers['Referrer-Policy'] = 'no-referrer'
|
||||
response.headers['Permissions-Policy'] = 'geolocation=(), microphone=()'
|
||||
return response
|
||||
|
||||
def ping_device(device_ip):
|
||||
try:
|
||||
output = subprocess.run(['ping', '-c', '1', '-w', '1', device_ip], check=True, capture_output=True, text=True)
|
||||
app.logger.info(output.stdout)
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
app.logger.info(f"Device was sleeping: {str(e)}")
|
||||
return False
|
||||
|
||||
def wake_on_lan(device_name, mac, iface):
|
||||
key = request.args.get('key')
|
||||
if not key or key != app.config['SECRET_KEY']:
|
||||
app.logger.warning(f"Unauthorized attempt to access {request.path}")
|
||||
return "Unauthorized\n", 401
|
||||
try:
|
||||
device_ip_var = f"{device_name.upper()}_IP"
|
||||
device_ip = os.getenv(device_ip_var)
|
||||
if device_ip:
|
||||
if ping_device(device_ip):
|
||||
app.logger.info(f"Device {device_name} is already up. No WoL packet sent.")
|
||||
return f"Device {device_name} is already up. No WoL packet sent.\n"
|
||||
|
||||
send_magic_packet(mac_address=mac, iface=iface)
|
||||
app.logger.info(f"WoL packet sent to {device_name}!")
|
||||
return f"WoL packet sent to {device_name}!\n"
|
||||
except Exception as e:
|
||||
app.logger.error(f"Failed to send WoL packet: {str(e)}")
|
||||
return "Failed to send WoL packet\n", 500
|
||||
|
||||
def get_device_mapping():
|
||||
device_mapping = {}
|
||||
for var in os.environ:
|
||||
if var.endswith('_MAC'):
|
||||
device_name = var[:-4].lower() # Get device name from environment variable name
|
||||
iface_var = f'{device_name.upper()}_IFACE'
|
||||
iface = os.getenv(iface_var, 'eth0') # Default to eth0 if interface is not set
|
||||
device_mapping[device_name] = {"mac_var": var, "iface": iface}
|
||||
return device_mapping
|
||||
|
||||
# Dynamic route for WoL
|
||||
@app.route('/wol/<device>')
|
||||
@auth.login_required
|
||||
@limiter.limit("10 per minute")
|
||||
def wol(device):
|
||||
device_mapping = get_device_mapping()
|
||||
name = device.lower()
|
||||
if name in device_mapping:
|
||||
mac = os.getenv(device_mapping[name]["mac_var"])
|
||||
iface = device_mapping[name]["iface"]
|
||||
if mac:
|
||||
#print(device)
|
||||
#print(mac)
|
||||
#print(iface)
|
||||
return wake_on_lan(name, mac, iface)
|
||||
else:
|
||||
app.logger.warning(f"MAC address for {device} is not set in the environment variables")
|
||||
return "MAC address not found\n", 400
|
||||
else:
|
||||
app.logger.warning(f"Invalid device: {device}")
|
||||
return "Invalid device\n", 400
|
||||
|
||||
@app.route('/get_key')
|
||||
@auth.login_required
|
||||
#@limiter.limit("5 per minute")
|
||||
@limiter.exempt
|
||||
def get_key():
|
||||
return jsonify({'key': app.config["SECRET_KEY"]})
|
||||
|
||||
@app.route('/ping/<device>')
|
||||
def ping(device):
|
||||
device_ip_var = f"{device.upper()}_IP"
|
||||
device_ip = os.getenv(device_ip_var)
|
||||
if device_ip:
|
||||
if ping_device(device_ip):
|
||||
return f"Device {device.lower()} is up.\n", 200
|
||||
else:
|
||||
return f"Device {device.lower()} is down or unreachable.\n", 404
|
||||
else:
|
||||
return "Device IP not found in environment variables\n", 400
|
||||
|
||||
@app.route('/redis_status')
|
||||
@auth.login_required
|
||||
def redis_status():
|
||||
try:
|
||||
redis_connection.ping()
|
||||
app.logger.info("Redis status endpoint: Connected to Redis")
|
||||
return "Connected to Redis\n", 200
|
||||
except Exception as e:
|
||||
app.logger.error(f"Failed to connect to Redis: {str(e)}")
|
||||
return f"Failed to connect to Redis: {str(e)}\n", 500
|
||||
|
||||
@app.route('/reset_limiter')
|
||||
@auth.login_required
|
||||
@limiter.exempt
|
||||
def reset_limiter():
|
||||
key = request.args.get('key')
|
||||
if not key or key != app.config['SECRET_KEY']:
|
||||
app.logger.warning("Unauthorized attempt to reset limiter")
|
||||
return "Unauthorized\n", 401
|
||||
# Delete all rate limiter keys
|
||||
try:
|
||||
keys = redis_connection.keys("LIMITER*")
|
||||
if not keys:
|
||||
app.logger.info("No rate limiter keys to delete.")
|
||||
return "No rate limiter keys to delete.\n"
|
||||
for key in keys:
|
||||
redis_connection.delete(key)
|
||||
app.logger.info(f"Deleted {len(keys)} rate limiter keys.")
|
||||
return f"Deleted {len(keys)} rate limiter keys.\n"
|
||||
except Exception as e:
|
||||
app.logger.error(f"Failed to reset limiter: {str(e)}")
|
||||
return f"Failed to reset limiter: {str(e)}\n", 500
|
||||
202
app/ui/Apache-2.0
Normal file
202
app/ui/Apache-2.0
Normal file
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
9
app/ui/__init__.py
Normal file
9
app/ui/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from flask import Blueprint
|
||||
|
||||
ui_bp = Blueprint(
|
||||
'ui',
|
||||
__name__,
|
||||
template_folder='templates', # Path to your HTML templates folder
|
||||
static_folder='static', # Path to your static files folder
|
||||
static_url_path='/ui/static' # URL path to access static files
|
||||
)
|
||||
1
app/ui/static/css/flexboxgrid.min.css
vendored
Normal file
1
app/ui/static/css/flexboxgrid.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
app/ui/static/css/fonts/TK3_WkUHHAIjg75cFRf3bXL8LICs13FvgUE.ttf
Normal file
BIN
app/ui/static/css/fonts/TK3_WkUHHAIjg75cFRf3bXL8LICs13FvgUE.ttf
Normal file
Binary file not shown.
BIN
app/ui/static/css/fonts/TK3_WkUHHAIjg75cFRf3bXL8LICs1y9ogUE.ttf
Normal file
BIN
app/ui/static/css/fonts/TK3_WkUHHAIjg75cFRf3bXL8LICs1y9ogUE.ttf
Normal file
Binary file not shown.
14
app/ui/static/css/fonts/oswald.css
Normal file
14
app/ui/static/css/fonts/oswald.css
Normal file
@@ -0,0 +1,14 @@
|
||||
@font-face {
|
||||
font-family: 'Oswald';
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
font-display: swap;
|
||||
src: url(./TK3_WkUHHAIjg75cFRf3bXL8LICs13FvgUE.ttf) format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Oswald';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(./TK3_WkUHHAIjg75cFRf3bXL8LICs1y9ogUE.ttf) format('truetype');
|
||||
}
|
||||
BIN
app/ui/static/css/images/img1.jpeg
Normal file
BIN
app/ui/static/css/images/img1.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
BIN
app/ui/static/css/images/img2.jpeg
Normal file
BIN
app/ui/static/css/images/img2.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
BIN
app/ui/static/css/images/img3.jpeg
Normal file
BIN
app/ui/static/css/images/img3.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
1
app/ui/static/css/reset.min.css
vendored
Normal file
1
app/ui/static/css/reset.min.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:'';content:none}table{border-collapse:collapse;border-spacing:0}
|
||||
419
app/ui/static/css/style.css
Normal file
419
app/ui/static/css/style.css
Normal file
@@ -0,0 +1,419 @@
|
||||
@import url("./fonts/oswald.css");
|
||||
|
||||
body {
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
font-family: "Oswald", sans-serif;
|
||||
background-color: #212121;
|
||||
}
|
||||
body section {
|
||||
width: 90%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
body section .row {
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
height: 400px;
|
||||
width: 100%;
|
||||
margin: 10px 0;
|
||||
transition: ease all 2.3s;
|
||||
perspective: 1200px;
|
||||
}
|
||||
.card:hover .cover {
|
||||
transform: rotateX(0deg) rotateY(-180deg);
|
||||
}
|
||||
.card:hover .cover:before {
|
||||
transform: translateZ(30px);
|
||||
}
|
||||
.card:hover .cover:after {
|
||||
background-color: black;
|
||||
}
|
||||
.card:hover .cover h1 {
|
||||
transform: translateZ(100px);
|
||||
}
|
||||
.card:hover .cover .price {
|
||||
transform: translateZ(60px);
|
||||
}
|
||||
.card:hover .cover a {
|
||||
transform: translateZ(-60px) rotatey(-180deg);
|
||||
}
|
||||
.card .cover {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
transform-style: preserve-3d;
|
||||
transition: ease all 2.3s;
|
||||
background-size: cover;
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.card .cover:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
border: 5px solid rgba(255, 255, 255, 0.5);
|
||||
box-shadow: 0 0 12px rgba(0, 0, 0, 0.3);
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
z-index: 2;
|
||||
transition: ease all 2.3s;
|
||||
transform-style: preserve-3d;
|
||||
transform: translateZ(0px);
|
||||
}
|
||||
.card .cover:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
bottom: 0px;
|
||||
z-index: 2;
|
||||
transition: ease all 1.3s;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.card .cover.item-a {
|
||||
background-image: url("./images/img1.jpeg");
|
||||
}
|
||||
.card .cover.item-b {
|
||||
background-image: url("./images/img2.jpeg");
|
||||
}
|
||||
.card .cover.item-c {
|
||||
background-image: url("./images/img3.jpeg");
|
||||
}
|
||||
.card .cover h1 {
|
||||
font-weight: 600;
|
||||
position: absolute;
|
||||
bottom: 55px;
|
||||
left: 50px;
|
||||
color: white;
|
||||
transform-style: preserve-3d;
|
||||
transition: ease all 2.3s;
|
||||
z-index: 3;
|
||||
font-size: 3em;
|
||||
transform: translateZ(0px);
|
||||
}
|
||||
.card .cover .price {
|
||||
font-weight: 200;
|
||||
position: absolute;
|
||||
top: 55px;
|
||||
right: 50px;
|
||||
color: white;
|
||||
transform-style: preserve-3d;
|
||||
transition: ease all 2.3s;
|
||||
z-index: 4;
|
||||
font-size: 2em;
|
||||
transform: translateZ(0px);
|
||||
}
|
||||
.card .card-back {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: #0b0f08;
|
||||
transform-style: preserve-3d;
|
||||
transition: ease all 2.3s;
|
||||
transform: translateZ(-1px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.card .card-back a {
|
||||
transform-style: preserve-3d;
|
||||
transition: ease transform 2.3s, ease background 0.5s;
|
||||
transform: translateZ(-1px) rotatey(-180deg);
|
||||
background: transparent;
|
||||
border: 1px solid white;
|
||||
font-weight: 200;
|
||||
font-size: 1.3em;
|
||||
color: white;
|
||||
padding: 14px 32px;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
.card .card-back a:hover {
|
||||
background-color: white;
|
||||
color: #0b0f08;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: #62f5c8;
|
||||
}
|
||||
.modal-wrapper {
|
||||
height: 100%;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
background: rgba(0, 0, 0, 0); /* Start with transparent background */
|
||||
box-sizing: border-box;
|
||||
cursor: auto;
|
||||
opacity: 1;
|
||||
overflow-y: auto;
|
||||
transition: 0.5s background ease, 0.5s opacity ease;
|
||||
z-index: 100;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 15px;
|
||||
display:none;
|
||||
opacity:0;
|
||||
}
|
||||
.modal-container {
|
||||
background-color: #001020;
|
||||
border-radius: 5px;
|
||||
margin: auto;
|
||||
max-width: 700px;
|
||||
min-width: 200px;
|
||||
position: relative;
|
||||
padding: 20px;
|
||||
color: #fff;
|
||||
transform: scale(0);
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
.modal-wrapper.show .modal-container {
|
||||
transform: scale(1);
|
||||
}
|
||||
.modal-wrapper.show {
|
||||
background: #254642d9;
|
||||
}
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #62f5c8;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 20px;
|
||||
z-index: 10;
|
||||
}
|
||||
.modal-heading {
|
||||
text-align: center; /* Center align the heading */
|
||||
}
|
||||
|
||||
.modal-timer {
|
||||
width: 0%;
|
||||
height: 4px;
|
||||
background: #42C0F2; /* Change color as needed */
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
border-top-right-radius: 5px;
|
||||
box-shadow: 0 0 8px #42C0F2;
|
||||
}
|
||||
.timer-animation {
|
||||
animation: countdown 5s linear forwards;
|
||||
}
|
||||
@keyframes countdown {
|
||||
from {
|
||||
width: 100%;
|
||||
}
|
||||
to {
|
||||
width: 0%;
|
||||
}
|
||||
}
|
||||
|
||||
.spinner {
|
||||
color: #45d6b5;
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.semi-circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
border: 10px solid transparent;
|
||||
border-top-color: transparent; /* Color for the first semi-circle */
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.semi-circle-1 {
|
||||
width: 300px; /* Smaller radius */ height: 300px; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||
border-top-color: #45d6b5;
|
||||
animation: rotate-circle-1 2s linear infinite;
|
||||
}
|
||||
|
||||
.semi-circle-2 {
|
||||
width: 350px; /* Medium radius */ height: 350px; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||
border-top-color: #ff5733;
|
||||
animation: rotate-circle-2 3s linear infinite;
|
||||
}
|
||||
|
||||
.semi-circle-3 {
|
||||
width: 400px; /* Larger radius */ height: 400px; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||
border-top-color: #42f58d;
|
||||
animation: rotate-circle-3 4s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate-circle-1 {
|
||||
0% { transform: translate(-50%, -50%) rotate(0deg); }
|
||||
100% { transform: translate(-50%, -50%) rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes rotate-circle-2 {
|
||||
0% { transform: translate(-50%, -50%) rotate(120deg); }
|
||||
100% { transform: translate(-50%, -50%) rotate(480deg); }
|
||||
}
|
||||
|
||||
@keyframes rotate-circle-3 {
|
||||
0% { transform: translate(-50%, -50%) rotate(240deg); }
|
||||
100% { transform: translate(-50%, -50%) rotate(600deg); }
|
||||
}
|
||||
|
||||
.preloader {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
width: 300px;
|
||||
height: 2px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
margin-bottom: 20px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.percentage {
|
||||
position: fixed;
|
||||
bottom: 32px;
|
||||
right: 32px;
|
||||
font-weight: 300;
|
||||
font-size: 15rem;
|
||||
line-height: 0.8;
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.text-container {
|
||||
height: 3em;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin: 20px 0;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-weight: 300;
|
||||
font-size: 1rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.02em;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-text.initial {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.loading-text.complete {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
.loading-text .char {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin: auto;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
visibility: hidden;
|
||||
z-index: 1;
|
||||
|
||||
overflow: auto;
|
||||
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content p {
|
||||
font-size: 1.2rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content .char {
|
||||
display: inline-block;
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Add these classes for the stagger animation */
|
||||
.preloader-item {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Media Queries for Mobile Devices */
|
||||
@media (max-width: 768px) {
|
||||
.spinner {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
}
|
||||
.semi-circle-1 {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
}
|
||||
.semi-circle-2 {
|
||||
width: 175px;
|
||||
height: 175px;
|
||||
}
|
||||
.semi-circle-3 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
.percentage {
|
||||
font-size: 10rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-height: calc(100vh - 7vh);
|
||||
}
|
||||
body section .row{
|
||||
padding-top: 3.5vh; /* Adjust this value as needed */
|
||||
padding-bottom: 3.5vh; /* Adjust this value as needed */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
}
|
||||
BIN
app/ui/static/favicon.ico
Normal file
BIN
app/ui/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 687 B |
260
app/ui/static/script.js
Normal file
260
app/ui/static/script.js
Normal file
@@ -0,0 +1,260 @@
|
||||
// Disable GSAP's use of requestAnimationFrame
|
||||
gsap.ticker.fps(60);
|
||||
gsap.ticker.lagSmoothing(0);
|
||||
|
||||
// Add an event listener to call showLoadingSpinner when the DOM is fully loaded
|
||||
// document.addEventListener('DOMContentLoaded', function() { showLoadingSpinner(); });
|
||||
|
||||
function select(selector) {
|
||||
return document.querySelector(selector);
|
||||
}
|
||||
|
||||
function showLoadingSpinner() {
|
||||
const spinner = select("#loading-spinner");
|
||||
spinner.style.display = "block";
|
||||
}
|
||||
|
||||
function hideLoadingSpinner() {
|
||||
const spinner = select("#loading-spinner");
|
||||
spinner.style.display = "none";
|
||||
}
|
||||
|
||||
document.addEventListener("click", debounce((e) => {
|
||||
if (e.target.closest(`[data-action="openModal"]`)) {
|
||||
const device = e.target.dataset.device;
|
||||
const action = e.target.dataset.actionType;
|
||||
let url = `/${action}/${device}`;
|
||||
if (action === "wol") {
|
||||
fetchKey().then((key) => {
|
||||
url += `?key=${key}`;
|
||||
showLoadingSpinner();
|
||||
openModal(url);
|
||||
}).catch(error => {
|
||||
console.error('Error fetching key:', error);
|
||||
});
|
||||
} else {
|
||||
showLoadingSpinner();
|
||||
openModal(url);
|
||||
}
|
||||
}
|
||||
if (
|
||||
e.target.closest(`[data-action="closeModal"]`) ||
|
||||
e.target.classList.contains("modal-wrapper")
|
||||
) {
|
||||
closeModal();
|
||||
}
|
||||
}, 300));
|
||||
|
||||
let countdown;
|
||||
|
||||
async function fetchKey() {
|
||||
try {
|
||||
const response = await fetch('/get_key');
|
||||
const data = await response.json();
|
||||
return data.key;
|
||||
} catch (error) {
|
||||
console.error('Error fetching key:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(url) {
|
||||
closeExistingModal().then(() => {
|
||||
fetch(url)
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
hideLoadingSpinner();
|
||||
const modal = `
|
||||
<div class="modal-wrapper">
|
||||
<div class="modal-container">
|
||||
<button class="modal-close" data-action="closeModal">
|
||||
<svg viewBox="0 0 20 20" width="16" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M 2 2 L 18 18" stroke-width="3" fill="transparent"></path>
|
||||
<path d="M 18 2 L 2 18" stroke-width="3" fill="transparent"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<h3 class="modal-heading">Server says</h3>
|
||||
<br>
|
||||
${html}
|
||||
<div class="modal-timer" id="modal-timer"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
select("body").insertAdjacentHTML("beforeend", modal);
|
||||
const wrapper = select(".modal-wrapper");
|
||||
wrapper.style.display = "flex";
|
||||
select("body").style.overflow = "hidden";
|
||||
setTimeout(() => {
|
||||
wrapper.style.opacity = 1;
|
||||
wrapper.classList.add("show");
|
||||
startCountdown();
|
||||
}, 100);
|
||||
}).catch(error => {
|
||||
hideLoadingSpinner();
|
||||
console.error('Error fetching content:', error)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
const wrapper = document.querySelector(".modal-wrapper");
|
||||
return new Promise((resolve) => {
|
||||
if (wrapper) {
|
||||
const container = document.querySelector(".modal-container");
|
||||
if (container) {
|
||||
container.style.transform = 'scale(0)';
|
||||
}
|
||||
wrapper.style.opacity = 0;
|
||||
setTimeout(() => {
|
||||
if (wrapper.parentNode) {
|
||||
wrapper.remove();
|
||||
}
|
||||
select("body").style.overflow = "";
|
||||
clearTimeout(countdown);
|
||||
resolve();
|
||||
}, 500);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function closeExistingModal() {
|
||||
const existingWrapper = document.querySelector(".modal-wrapper");
|
||||
return new Promise((resolve) => {
|
||||
if (existingWrapper) {
|
||||
existingWrapper.style.opacity = 0;
|
||||
setTimeout(() => {
|
||||
if (existingWrapper.parentNode) {
|
||||
existingWrapper.remove();
|
||||
}
|
||||
select("body").style.overflow = ""; // Reset body overflow
|
||||
clearTimeout(countdown); // Clear any existing countdown
|
||||
resolve();
|
||||
}, 500);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function startCountdown() {
|
||||
const timer = document.getElementById('modal-timer');
|
||||
if (timer) {
|
||||
timer.classList.add('timer-animation');
|
||||
countdown = setTimeout(() => {
|
||||
closeModal();
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
/* Debounce Function */
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function(...args) {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(this, args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
window.onload = function() {
|
||||
|
||||
|
||||
document.querySelector(".preloader").style.display = 'flex';
|
||||
|
||||
const loadingText = new SplitType(".loading-text.initial", { types: "chars" });
|
||||
const completeText = new SplitType(".loading-text.complete", { types: "chars" });
|
||||
const titleText = new SplitType(".content h1", { types: "chars" });
|
||||
const paragraphText = new SplitType(".content p", { types: "chars" });
|
||||
|
||||
gsap.set(".loading-text.complete", { y: "100%" });
|
||||
gsap.set(loadingText.chars, { opacity: 0, y: 100 });
|
||||
gsap.set(completeText.chars, { opacity: 0, y: 100 });
|
||||
|
||||
gsap.to(loadingText.chars, {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: 0.5,
|
||||
stagger: 0.05,
|
||||
ease: "power2.out"
|
||||
});
|
||||
|
||||
|
||||
const colorStages = [
|
||||
{ bg: "rgb(60, 66, 55)", text: "rgb(230, 225, 215)" },
|
||||
{ bg: "rgb(200, 180, 160)", text: "rgb(60, 66, 55)" },
|
||||
{ bg: "rgb(230, 225, 215)", text: "rgb(60, 66, 55)" },
|
||||
{ bg: "rgb(100, 110, 90)", text: "rgb(230, 225, 215)" }
|
||||
];
|
||||
|
||||
function updateColors(progress) {
|
||||
const stage = Math.floor(progress / 25);
|
||||
if (stage < colorStages.length) {
|
||||
document.querySelector(".preloader").style.backgroundColor = colorStages[stage].bg;
|
||||
document.querySelector(".progress-bar").style.backgroundColor = colorStages[stage].text;
|
||||
document.querySelectorAll(".loading-text .char, .percentage").forEach((el) => {
|
||||
el.style.color = colorStages[stage].text;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to(".progress-bar", {
|
||||
width: "100%",
|
||||
duration: 5,
|
||||
ease: "power1.inOut",
|
||||
onUpdate: function () {
|
||||
const progress = Math.round(this.progress() * 100);
|
||||
document.querySelector(".percentage").textContent = progress;
|
||||
updateColors(progress);
|
||||
}
|
||||
}).to(".loading-text.initial", {
|
||||
y: "-100%",
|
||||
duration: 0.5,
|
||||
ease: "power2.inOut"
|
||||
}).to(".loading-text.complete", {
|
||||
y: "0%",
|
||||
duration: 0.5,
|
||||
ease: "power2.inOut"
|
||||
}, "<" ).to(completeText.chars, {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: 0.3,
|
||||
stagger: 0.03,
|
||||
ease: "power2.out"
|
||||
}, "<0.2" ).to(".preloader", {
|
||||
y: "-100vh",
|
||||
duration: 1,
|
||||
ease: "power2.inOut",
|
||||
delay: 0.8
|
||||
}).set(".content", {
|
||||
visibility: "visible"
|
||||
}, "-=1").to([titleText.chars, paragraphText.chars], {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: 1,
|
||||
stagger: 0.02,
|
||||
ease: "power4.out"
|
||||
}, "-=0.5").set(".preloader", {
|
||||
display: "none" });
|
||||
|
||||
let isFirstLoad = !localStorage.getItem('firstLoadDone');
|
||||
if (!isFirstLoad) {
|
||||
const tl = gsap.timeline();
|
||||
tl.set(".preloader", {
|
||||
display: "none"
|
||||
}).set(".content", {
|
||||
visibility: "visible"
|
||||
}, "-=1").to([titleText.chars, paragraphText.chars], {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: 2,
|
||||
stagger: 0.04,
|
||||
ease: "power2.out"
|
||||
}, "=0.5")
|
||||
} else {
|
||||
document.querySelector('.preloader').style.display = 'flex';
|
||||
tl.play(); // Start the GSAP timeline
|
||||
localStorage.setItem('firstLoadDone', 'true');
|
||||
}
|
||||
|
||||
};
|
||||
8
app/ui/static/split-type.js
Normal file
8
app/ui/static/split-type.js
Normal file
File diff suppressed because one or more lines are too long
64
app/ui/templates/home.html
Normal file
64
app/ui/templates/home.html
Normal file
@@ -0,0 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<link rel="stylesheet" href="{{ url_for('ui.static', filename='css/style.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('ui.static', filename='css/reset.min.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('ui.static', filename='css/flexboxgrid.min.css') }}">
|
||||
<link rel="icon" href="{{ url_for('ui.static', filename='favicon.ico') }}" type="image/x-icon">
|
||||
|
||||
<script src="{{ url_for('ui.static', filename='split-type.js') }}"></script>
|
||||
<script src="{{ url_for('ui.static', filename='gsap.min.js') }}"></script>
|
||||
<script src="{{ url_for('ui.static', filename='script.js') }}"></script>
|
||||
|
||||
<title>Devices</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="preloader">
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar"></div>
|
||||
</div>
|
||||
<div class="text-container">
|
||||
<div class="loading-text initial">Loading</div>
|
||||
<div class="loading-text complete">Complete</div>
|
||||
</div>
|
||||
<div class="percentage">0</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
|
||||
<section>
|
||||
<div class="row">
|
||||
{% set classes = ["item-a", "item-b", "item-c"] %}
|
||||
{% for device in devices %}
|
||||
<div class="col-md-4 col-sm-6 col-xs-12">
|
||||
<div class="card">
|
||||
<div class="cover {{ classes[loop.index0 % classes|length] }}">
|
||||
<h1>{{ device|capitalize }}</h1>
|
||||
<div class="card-back">
|
||||
<a href="javascript:void(0);" data-action="openModal" data-action-type="wol" data-device="{{ device }}">Wake Up</a>
|
||||
<a href="javascript:void(0);" data-action="openModal" data-action-type="ping" data-device="{{ device }}">Ping</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="loading-spinner" class="spinner" style="display: none;">
|
||||
|
||||
<div class="semi-circle semi-circle-1"></div>
|
||||
<div class="semi-circle semi-circle-2"></div>
|
||||
<div class="semi-circle semi-circle-3"></div>
|
||||
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"> <path opacity="0.3"> <animate attributeName="d" dur="1s" repeatCount="indefinite" values="M12 2 Q9 2.2 7.3 7 Q6.5 9.5 6.5 12 Q6.5 15 7.3 17 Q9 22 12 22;M12 2 Q6.5 2.2 3.3 7 Q2 9.5 2 12 Q2 15 3.4 17 Q6.5 22 12 22;" /> </path> <path opacity="0.3"> <animate attributeName="d" dur="1s" repeatCount="indefinite" values="M12 2 Q12 2.2 12 7 Q12 9.5 12 12 Q12 15 12 17 Q12 22 12 22;M12 2 Q9 2.2 7.3 7 Q6.5 9.5 6.5 12 Q6.5 15 7.3 17 Q9 22 12 22;" /> </path> <path opacity="0.3"> <animate attributeName="d" dur="1s" repeatCount="indefinite" values="M12 2 Q15 2.2 16.6 7 Q17.5 9.5 17.5 12 Q17.5 15 16.7 17 Q15 22 12 22;M12 2 Q12 2.2 12 7 Q12 9.5 12 12 Q12 15 12 17 Q12 22 12 22;" /> </path> <path opacity="0.3"> <animate attributeName="d" dur="1s" repeatCount="indefinite" values="M12 2 Q17.5 2.2 20.7 7 Q22 9.5 22 12 Q22 15 20.6 17 Q17.5 22 12 22;M12 2 Q15 2.2 16.6 7 Q17.5 9.5 17.5 12 Q17.5 15 16.7 17 Q15 22 12 22;" /> </path> <circle cx="12" cy="12" r="10" /> <path d="M2.4 8.6 Q6 7.1 12 7 Q18 7.1 21.6 8.6" /> <path d="M2.4 15.2 Q6 17.1 12 17.2 Q17 17.1 21.6 15.2" /> <path> <animate attributeName="d" dur="1s" repeatCount="indefinite" values="M12 2 Q6.5 2.2 3.3 7 Q2 9.5 2 12 Q2 15 3.4 17 Q6.5 22 12 22;M12 2 Q9 2.2 7.3 7 Q6.5 9.5 6.5 12 Q6.5 15 7.3 17 Q9 22 12 22;" /> </path> <path> <animate attributeName="d" dur="1s" repeatCount="indefinite" values="M12 2 Q9 2.2 7.3 7 Q6.5 9.5 6.5 12 Q6.5 15 7.3 17 Q9 22 12 22;M12 2 Q12 2.2 12 7 Q12 9.5 12 12 Q12 15 12 17 Q12 22 12 22;" /> </path> <path> <animate attributeName="d" dur="1s" repeatCount="indefinite" values="M12 2 Q12 2.2 12 7 Q12 9.5 12 12 Q12 15 12 17 Q12 22 12 22;M12 2 Q15 2.2 16.6 7 Q17.5 9.5 17.5 12 Q17.5 15 16.7 17 Q15 22 12 22;M12 2 Q17.5 2.2 20.7 7 Q22 9.5 22 12 Q22 15 20.6 17 Q17.5 22 12 22;" /> </path> </svg>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
33
app/ui/ui.py
Normal file
33
app/ui/ui.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from flask import Flask, render_template, request, send_from_directory
|
||||
import httpx
|
||||
import asyncio
|
||||
from dotenv import load_dotenv
|
||||
from . import ui_bp
|
||||
import os
|
||||
|
||||
load_dotenv() # Load environment variables from .env file
|
||||
|
||||
def get_devices():
|
||||
devices = []
|
||||
for key in os.environ:
|
||||
if key.endswith('_MAC'):
|
||||
device_name = key[:-4].lower() # Get device name
|
||||
devices.append(device_name)
|
||||
return devices
|
||||
|
||||
def setup_ui(limiter, app):
|
||||
|
||||
# Serve static files and exempt from rate limiting
|
||||
@app.route('/ui/static/<path:filename>')
|
||||
@limiter.exempt
|
||||
def serve_static(filename):
|
||||
static_dir = os.path.join(app.root_path, 'ui', 'static')
|
||||
return send_from_directory(static_dir, filename)
|
||||
|
||||
@ui_bp.route('/')
|
||||
@limiter.exempt
|
||||
def home():
|
||||
devices = get_devices()
|
||||
return render_template('home.html', devices=devices)
|
||||
|
||||
app.register_blueprint(ui_bp)
|
||||
36
app/wolServer.py
Normal file
36
app/wolServer.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from flask import Flask
|
||||
from config import Config # Importing the configuration
|
||||
from auth import auth # Importing authentication setup
|
||||
from routes import setup_routes # Importing route setup
|
||||
from logging_config import setup_logging # Importing logging setup
|
||||
from redis_config import setup_redis # Import setup_redis
|
||||
from limiter_config import setup_limiter # Import limiter
|
||||
from ui.ui import setup_ui # Import setup_ui
|
||||
import ssl
|
||||
|
||||
# Initialize Flask app
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
|
||||
# Set up logging
|
||||
setup_logging(app)
|
||||
|
||||
# Set up Redis and get the connection
|
||||
redis_connection = setup_redis(app)
|
||||
if not redis_connection:
|
||||
raise RuntimeError("Failed to connect to Redis")
|
||||
|
||||
# Setup limiter
|
||||
limiter = setup_limiter(app)
|
||||
|
||||
# Setup authentication and routes
|
||||
setup_routes(limiter, app, redis_connection)
|
||||
|
||||
# Setup the UI
|
||||
setup_ui(limiter, app)
|
||||
|
||||
if __name__ == '__main__':
|
||||
# SSL context setup
|
||||
context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
||||
context.load_cert_chain('../certs/mycert.crt', '../certs/mycert.key')
|
||||
app.run(host='0.0.0.0', port=51820, ssl_context=context)
|
||||
51
app/wol_utils.py
Normal file
51
app/wol_utils.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import socket
|
||||
import struct
|
||||
# import logging
|
||||
|
||||
# Setup logging
|
||||
# logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
def create_magic_packet(mac_address):
|
||||
"""Create a magic packet for the given MAC address."""
|
||||
|
||||
# Remove any separation characters from the MAC address
|
||||
mac_address = mac_address.replace(":", "").replace("-", "")
|
||||
|
||||
# Verify length of MAC address
|
||||
# if len(mac_address) != 12:
|
||||
# raise ValueError("Invalid MAC address format")
|
||||
|
||||
# Create the magic packet by repeating the MAC address 16 times
|
||||
mac_bytes = bytes.fromhex(mac_address)
|
||||
magic_packet = b'\xff' * 6 + mac_bytes * 16
|
||||
|
||||
return magic_packet
|
||||
|
||||
def send_magic_packet(mac_address, iface="eth0"):
|
||||
try:
|
||||
|
||||
magic_packet = create_magic_packet(mac_address)
|
||||
|
||||
# Create a raw socket
|
||||
sock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW)
|
||||
# logging.debug(f"Binding to interface: {iface}")
|
||||
sock.bind((iface, 0))
|
||||
|
||||
# Log the packet for debugging
|
||||
# logging.debug(f"Sending Magic Packet: {magic_packet.hex()} on interface {iface}")
|
||||
|
||||
# Construct the Ethernet frame (magic packet)
|
||||
dest_mac = b'\xff\xff\xff\xff\xff\xff' # Broadcast MAC address
|
||||
src_mac = sock.getsockname()[4] # Source MAC address of the interface
|
||||
ether_type = struct.pack('!H', 0x0842) # Wake-on-LAN
|
||||
frame = dest_mac + src_mac + ether_type + magic_packet
|
||||
|
||||
# Send the Ethernet frame
|
||||
sock.send(frame)
|
||||
sock.close()
|
||||
# logging.debug(f"Magic packet sent to {mac_address} on interface {iface}")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send WoL packet: {e}")
|
||||
|
||||
# Example usage
|
||||
# send_magic_packet("mac goes here")
|
||||
Reference in New Issue
Block a user