This commit is contained in:
parent
e3138cdbee
commit
f424db847d
41
.gitea/workflows/build.yml
Normal file
41
.gitea/workflows/build.yml
Normal file
@ -0,0 +1,41 @@
|
||||
name: Build Docker
|
||||
run-name: Build Docker Images
|
||||
on:
|
||||
push:
|
||||
|
||||
jobs:
|
||||
Build Image:
|
||||
runs-on: [ubuntu-latest, amd]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: Install Docker
|
||||
run : |
|
||||
apt-get install ca-certificates curl gnupg
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
chmod a+r /etc/apt/keyrings/docker.gpg
|
||||
echo "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian "$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
apt-get update
|
||||
apt-get install docker-ce-cli -y
|
||||
- name: Build Docker image
|
||||
run : |
|
||||
echo "${{ secrets.DOCKERGIT_TOKEN }}" | docker login git.woodburn.au -u nathanwoodburn --password-stdin
|
||||
echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}"
|
||||
tag=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}
|
||||
tag=${tag//\//-}
|
||||
tag_num=${GITHUB_RUN_NUMBER}
|
||||
echo "tag_num=$tag_num"
|
||||
|
||||
if [[ "$tag" == "main" ]]; then
|
||||
tag="latest"
|
||||
else
|
||||
tag_num="${tag}-${tag_num}"
|
||||
fi
|
||||
|
||||
|
||||
docker build -t shakecities:$tag_num .
|
||||
docker tag shakecities:$tag_num git.woodburn.au/nathanwoodburn/shakecities:$tag_num
|
||||
docker push git.woodburn.au/nathanwoodburn/shakecities:$tag_num
|
||||
docker tag shakecities:$tag_num git.woodburn.au/nathanwoodburn/shakecities:$tag
|
||||
docker push git.woodburn.au/nathanwoodburn/shakecities:$tag
|
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
|
||||
.env
|
||||
|
||||
__pycache__/
|
||||
|
||||
*.json
|
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@ -0,0 +1,17 @@
|
||||
FROM --platform=$BUILDPLATFORM python:3.10-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt /app
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip3 install -r requirements.txt
|
||||
|
||||
COPY . /app
|
||||
|
||||
# Add mount point for data volume
|
||||
VOLUME /data
|
||||
|
||||
ENTRYPOINT ["python3"]
|
||||
CMD ["server.py", "sldserver.py"]
|
||||
|
||||
FROM builder as dev-envs
|
69
accounts.py
Normal file
69
accounts.py
Normal file
@ -0,0 +1,69 @@
|
||||
import os
|
||||
import dotenv
|
||||
from passlib.hash import argon2
|
||||
import json
|
||||
|
||||
dotenv.load_dotenv()
|
||||
local = os.getenv('LOCAL')
|
||||
|
||||
def hash_password(password):
|
||||
return argon2.using(rounds=16).hash(password)
|
||||
|
||||
# Verify a password against a hashed password
|
||||
def verify_password(password, hashed_password):
|
||||
return argon2.verify(password, hashed_password)
|
||||
|
||||
def generate_cookie():
|
||||
token = os.urandom(24).hex()
|
||||
# Verify token doesn't already exist
|
||||
with open('users.json', 'r') as f:
|
||||
users = json.load(f)
|
||||
for user in users:
|
||||
if token in user['tokens']:
|
||||
print('Token already exists, generating new one')
|
||||
return generate_cookie()
|
||||
|
||||
return token
|
||||
|
||||
# Create a new user
|
||||
def create_user(email, domain, password):
|
||||
# Hash password
|
||||
hashed_password = hash_password(password)
|
||||
# Create user
|
||||
user = {
|
||||
'email': email,
|
||||
'domain': domain,
|
||||
'password': hashed_password
|
||||
}
|
||||
|
||||
# Create a cookie
|
||||
token = generate_cookie()
|
||||
user['tokens'] = [token]
|
||||
|
||||
# If file doesn't exist, create it
|
||||
if not os.path.isfile('users.json'):
|
||||
with open('users.json', 'w') as f:
|
||||
json.dump([], f)
|
||||
|
||||
|
||||
|
||||
# Write to file
|
||||
with open('users.json', 'r') as f:
|
||||
users = json.load(f)
|
||||
|
||||
for u in users:
|
||||
if u['email'] == email:
|
||||
return {'success': False, 'message': 'Email already exists'}
|
||||
|
||||
users.append(user)
|
||||
with open('users.json', 'w') as f:
|
||||
json.dump(users, f)
|
||||
return {'success': True, 'message': 'User created', 'token': token}
|
||||
|
||||
def validate_token(token):
|
||||
with open('users.json', 'r') as f:
|
||||
users = json.load(f)
|
||||
for user in users:
|
||||
if token in user['tokens']:
|
||||
return user
|
||||
return False
|
77
main.py
Normal file
77
main.py
Normal file
@ -0,0 +1,77 @@
|
||||
from flask import Flask, make_response, redirect, request, jsonify, render_template, send_from_directory
|
||||
import os
|
||||
import dotenv
|
||||
import requests
|
||||
import json
|
||||
import schedule
|
||||
import time
|
||||
from email_validator import validate_email, EmailNotValidError
|
||||
import accounts
|
||||
|
||||
app = Flask(__name__)
|
||||
dotenv.load_dotenv()
|
||||
|
||||
|
||||
#Assets routes
|
||||
@app.route('/assets/<path:path>')
|
||||
def assets(path):
|
||||
return send_from_directory('templates/assets', path)
|
||||
|
||||
#! TODO make prettier
|
||||
def error(message):
|
||||
return jsonify({'success': False, 'message': message}), 400
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
if 'token' in request.cookies:
|
||||
token = request.cookies['token']
|
||||
# Verify token
|
||||
user = accounts.validate_token(token)
|
||||
if not user:
|
||||
# Remove cookie
|
||||
resp = make_response(redirect('/'))
|
||||
resp.set_cookie('token', '', expires=0)
|
||||
return resp
|
||||
return render_template('index.html',account=user['email'],account_link="account")
|
||||
return render_template('index.html',account="Login",account_link="login")
|
||||
|
||||
@app.route('/signup', methods=['POST'])
|
||||
def signup():
|
||||
email = request.form['email']
|
||||
domain = request.form['domain']
|
||||
password = request.form['password']
|
||||
print("New signup for " + email + " | " + domain)
|
||||
try:
|
||||
valid = validate_email(email)
|
||||
email = valid.email
|
||||
user = accounts.create_user(email, domain, password)
|
||||
if not user['success']:
|
||||
return error(user['message'])
|
||||
|
||||
# Redirect to dashboard with cookie
|
||||
resp = make_response(redirect('/edit'))
|
||||
resp.set_cookie('token', user['token'])
|
||||
return resp
|
||||
|
||||
except EmailNotValidError as e:
|
||||
return jsonify({'success': False, 'message': 'Invalid email'}), 400
|
||||
|
||||
@app.route('/<path:path>')
|
||||
def catch_all(path):
|
||||
# If file exists, load it
|
||||
if os.path.isfile('templates/' + path):
|
||||
return render_template(path)
|
||||
|
||||
# Try with .html
|
||||
if os.path.isfile('templates/' + path + '.html'):
|
||||
return render_template(path + '.html')
|
||||
return redirect('/') # 404 catch all
|
||||
|
||||
# 404 catch all
|
||||
@app.errorhandler(404)
|
||||
def not_found(e):
|
||||
return redirect('/')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=False, port=5000, host='0.0.0.0')
|
10
requirements.txt
Normal file
10
requirements.txt
Normal file
@ -0,0 +1,10 @@
|
||||
flask
|
||||
python-dotenv
|
||||
gunicorn
|
||||
requests
|
||||
pyotp
|
||||
schedule
|
||||
email-validator
|
||||
py3dns
|
||||
passlib
|
||||
argon2-cffi
|
42
server.py
Normal file
42
server.py
Normal file
@ -0,0 +1,42 @@
|
||||
import time
|
||||
from flask import Flask
|
||||
from main import app
|
||||
import main
|
||||
from gunicorn.app.base import BaseApplication
|
||||
import os
|
||||
import dotenv
|
||||
import sys
|
||||
import json
|
||||
|
||||
|
||||
class GunicornApp(BaseApplication):
|
||||
def __init__(self, app, options=None):
|
||||
self.options = options or {}
|
||||
self.application = app
|
||||
super().__init__()
|
||||
|
||||
def load_config(self):
|
||||
for key, value in self.options.items():
|
||||
if key in self.cfg.settings and value is not None:
|
||||
self.cfg.set(key.lower(), value)
|
||||
|
||||
def load(self):
|
||||
return self.application
|
||||
|
||||
if __name__ == '__main__':
|
||||
workers = os.getenv('WORKERS')
|
||||
threads = os.getenv('THREADS')
|
||||
if workers is None:
|
||||
workers = 1
|
||||
if threads is None:
|
||||
threads = 2
|
||||
workers = int(workers)
|
||||
threads = int(threads)
|
||||
options = {
|
||||
'bind': '0.0.0.0:5000',
|
||||
'workers': workers,
|
||||
'threads': threads,
|
||||
}
|
||||
gunicorn_app = GunicornApp(app, options)
|
||||
print('Starting server with ' + str(workers) + ' workers and ' + str(threads) + ' threads', flush=True)
|
||||
gunicorn_app.run()
|
48
slds.py
Normal file
48
slds.py
Normal file
@ -0,0 +1,48 @@
|
||||
from flask import Flask, make_response, redirect, request, jsonify, render_template, send_from_directory
|
||||
import os
|
||||
import dotenv
|
||||
import requests
|
||||
import json
|
||||
import schedule
|
||||
import time
|
||||
from email_validator import validate_email, EmailNotValidError
|
||||
import accounts
|
||||
|
||||
app = Flask(__name__)
|
||||
dotenv.load_dotenv()
|
||||
|
||||
|
||||
#Assets routes
|
||||
@app.route('/assets/<path:path>')
|
||||
def assets(path):
|
||||
return send_from_directory('templates/assets', path)
|
||||
|
||||
#! TODO make prettier
|
||||
def error(message):
|
||||
return jsonify({'success': False, 'message': message}), 400
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
host = request.host
|
||||
return jsonify({'success': True, 'message': host})
|
||||
|
||||
|
||||
@app.route('/<path:path>')
|
||||
def catch_all(path):
|
||||
# If file exists, load it
|
||||
if os.path.isfile('templates/' + path):
|
||||
return render_template(path)
|
||||
|
||||
# Try with .html
|
||||
if os.path.isfile('templates/' + path + '.html'):
|
||||
return render_template(path + '.html')
|
||||
return redirect('/') # 404 catch all
|
||||
|
||||
# 404 catch all
|
||||
@app.errorhandler(404)
|
||||
def not_found(e):
|
||||
return redirect('/')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=False, port=5000, host='0.0.0.0')
|
42
sldserver.py
Normal file
42
sldserver.py
Normal file
@ -0,0 +1,42 @@
|
||||
import time
|
||||
from flask import Flask
|
||||
from slds import app
|
||||
import slds
|
||||
from gunicorn.app.base import BaseApplication
|
||||
import os
|
||||
import dotenv
|
||||
import sys
|
||||
import json
|
||||
|
||||
|
||||
class GunicornApp(BaseApplication):
|
||||
def __init__(self, app, options=None):
|
||||
self.options = options or {}
|
||||
self.application = app
|
||||
super().__init__()
|
||||
|
||||
def load_config(self):
|
||||
for key, value in self.options.items():
|
||||
if key in self.cfg.settings and value is not None:
|
||||
self.cfg.set(key.lower(), value)
|
||||
|
||||
def load(self):
|
||||
return self.application
|
||||
|
||||
if __name__ == '__main__':
|
||||
workers = os.getenv('WORKERS')
|
||||
threads = os.getenv('THREADS')
|
||||
if workers is None:
|
||||
workers = 1
|
||||
if threads is None:
|
||||
threads = 2
|
||||
workers = int(workers)
|
||||
threads = int(threads)
|
||||
options = {
|
||||
'bind': '0.0.0.0:5001',
|
||||
'workers': workers,
|
||||
'threads': threads,
|
||||
}
|
||||
gunicorn_app = GunicornApp(app, options)
|
||||
print('Starting server with ' + str(workers) + ' workers and ' + str(threads) + ' threads', flush=True)
|
||||
gunicorn_app.run()
|
5
templates/assets/bootstrap/css/bootstrap.min.css
vendored
Normal file
5
templates/assets/bootstrap/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
6
templates/assets/bootstrap/js/bootstrap.min.js
vendored
Normal file
6
templates/assets/bootstrap/js/bootstrap.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
templates/assets/css/styles.min.css
vendored
Normal file
1
templates/assets/css/styles.min.css
vendored
Normal file
@ -0,0 +1 @@
|
||||
.bs-icon{--bs-icon-size:.75rem;display:flex;flex-shrink:0;justify-content:center;align-items:center;font-size:var(--bs-icon-size);width:calc(var(--bs-icon-size) * 2);height:calc(var(--bs-icon-size) * 2);color:var(--bs-primary)}.bs-icon-xs{--bs-icon-size:1rem;width:calc(var(--bs-icon-size) * 1.5);height:calc(var(--bs-icon-size) * 1.5)}.bs-icon-sm{--bs-icon-size:1rem}.bs-icon-md{--bs-icon-size:1.5rem}.bs-icon-lg{--bs-icon-size:2rem}.bs-icon-xl{--bs-icon-size:2.5rem}.bs-icon.bs-icon-primary{color:var(--bs-white);background:var(--bs-primary)}.bs-icon.bs-icon-primary-light{color:var(--bs-primary);background:rgba(var(--bs-primary-rgb),.2)}.bs-icon.bs-icon-semi-white{color:var(--bs-primary);background:rgba(255,255,255,.5)}.bs-icon.bs-icon-rounded{border-radius:.5rem}.bs-icon.bs-icon-circle{border-radius:50%}
|
31
templates/index.html
Normal file
31
templates/index.html
Normal file
@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html data-bs-theme="light" lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
|
||||
<title>shakecities</title>
|
||||
<link rel="stylesheet" href="assets/bootstrap/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="assets/css/styles.min.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-md bg-dark py-3" data-bs-theme="dark">
|
||||
<div class="container"><a class="navbar-brand d-flex align-items-center" href="/"><span class="bs-icon-sm bs-icon-rounded bs-icon-primary d-flex justify-content-center align-items-center me-2 bs-icon"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" class="bi bi-bezier">
|
||||
<path fill-rule="evenodd" d="M0 10.5A1.5 1.5 0 0 1 1.5 9h1A1.5 1.5 0 0 1 4 10.5v1A1.5 1.5 0 0 1 2.5 13h-1A1.5 1.5 0 0 1 0 11.5v-1zm1.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1zm10.5.5A1.5 1.5 0 0 1 13.5 9h1a1.5 1.5 0 0 1 1.5 1.5v1a1.5 1.5 0 0 1-1.5 1.5h-1a1.5 1.5 0 0 1-1.5-1.5v-1zm1.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1zM6 4.5A1.5 1.5 0 0 1 7.5 3h1A1.5 1.5 0 0 1 10 4.5v1A1.5 1.5 0 0 1 8.5 7h-1A1.5 1.5 0 0 1 6 5.5v-1zM7.5 4a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1z"></path>
|
||||
<path d="M6 4.5H1.866a1 1 0 1 0 0 1h2.668A6.517 6.517 0 0 0 1.814 9H2.5c.123 0 .244.015.358.043a5.517 5.517 0 0 1 3.185-3.185A1.503 1.503 0 0 1 6 5.5v-1zm3.957 1.358A1.5 1.5 0 0 0 10 5.5v-1h4.134a1 1 0 1 1 0 1h-2.668a6.517 6.517 0 0 1 2.72 3.5H13.5c-.123 0-.243.015-.358.043a5.517 5.517 0 0 0-3.185-3.185z"></path>
|
||||
</svg></span><span>Brand</span></a><button data-bs-toggle="collapse" class="navbar-toggler" data-bs-target="#navcol-6"><span class="visually-hidden">Toggle navigation</span><span class="navbar-toggler-icon"></span></button>
|
||||
<div class="collapse navbar-collapse flex-grow-0 order-md-first" id="navcol-6">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item"><a class="nav-link active" href="/signup">Create your page</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/edit">Edit your page</a></li>
|
||||
</ul>
|
||||
<div class="d-md-none my-2"><button class="btn btn-light me-2" type="button">Button</button><button class="btn btn-primary" type="button">Button</button></div>
|
||||
</div>
|
||||
<div class="d-none d-md-block"><a class="btn btn-primary" role="button" href="/{{account_link}}">{{account}}</a></div>
|
||||
</div>
|
||||
</nav>
|
||||
<script src="assets/bootstrap/js/bootstrap.min.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
41
templates/signup.html
Normal file
41
templates/signup.html
Normal file
@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html data-bs-theme="light" lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
|
||||
<title>shakecities</title>
|
||||
<link rel="stylesheet" href="assets/bootstrap/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="assets/css/styles.min.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-md bg-dark py-3" data-bs-theme="dark">
|
||||
<div class="container"><a class="navbar-brand d-flex align-items-center" href="/"><span class="bs-icon-sm bs-icon-rounded bs-icon-primary d-flex justify-content-center align-items-center me-2 bs-icon"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" class="bi bi-bezier">
|
||||
<path fill-rule="evenodd" d="M0 10.5A1.5 1.5 0 0 1 1.5 9h1A1.5 1.5 0 0 1 4 10.5v1A1.5 1.5 0 0 1 2.5 13h-1A1.5 1.5 0 0 1 0 11.5v-1zm1.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1zm10.5.5A1.5 1.5 0 0 1 13.5 9h1a1.5 1.5 0 0 1 1.5 1.5v1a1.5 1.5 0 0 1-1.5 1.5h-1a1.5 1.5 0 0 1-1.5-1.5v-1zm1.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1zM6 4.5A1.5 1.5 0 0 1 7.5 3h1A1.5 1.5 0 0 1 10 4.5v1A1.5 1.5 0 0 1 8.5 7h-1A1.5 1.5 0 0 1 6 5.5v-1zM7.5 4a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1z"></path>
|
||||
<path d="M6 4.5H1.866a1 1 0 1 0 0 1h2.668A6.517 6.517 0 0 0 1.814 9H2.5c.123 0 .244.015.358.043a5.517 5.517 0 0 1 3.185-3.185A1.503 1.503 0 0 1 6 5.5v-1zm3.957 1.358A1.5 1.5 0 0 0 10 5.5v-1h4.134a1 1 0 1 1 0 1h-2.668a6.517 6.517 0 0 1 2.72 3.5H13.5c-.123 0-.243.015-.358.043a5.517 5.517 0 0 0-3.185-3.185z"></path>
|
||||
</svg></span><span>Brand</span></a><button data-bs-toggle="collapse" class="navbar-toggler" data-bs-target="#navcol-6"><span class="visually-hidden">Toggle navigation</span><span class="navbar-toggler-icon"></span></button>
|
||||
<div class="collapse navbar-collapse flex-grow-0 order-md-first" id="navcol-6">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item"><a class="nav-link active" href="/signup">Create your page</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/edit">Edit your page</a></li>
|
||||
</ul>
|
||||
<div class="d-md-none my-2"><button class="btn btn-light me-2" type="button">Button</button><button class="btn btn-primary" type="button">Button</button></div>
|
||||
</div>
|
||||
<div class="d-none d-md-block"><a class="btn btn-primary" role="button" href="#">{{account}}</a></div>
|
||||
</div>
|
||||
</nav>
|
||||
<section style="width: 50%;margin: auto;margin-top: 50px;">
|
||||
<div class="card bg-dark" style="padding-bottom: 40px;">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title" style="color: rgb(255,255,255);">Sign up for your free page</h4>
|
||||
</div>
|
||||
<form style="width: 80%;margin: auto;text-align: right;margin-top: 20px;" method="post">
|
||||
<p class="tld" style="position: relative;display: inline;color: rgb(0,0,0);margin-right: 10px;">.exampledomainnathan1</p><input class="form-control" type="text" id="domain" style="margin: auto;width: 100%;margin-top: -30px;" placeholder="yourname" name="domain" required=""><input class="form-control" type="email" style="margin: auto;width: 100%;margin-top: 10px;" placeholder="email" name="email" required=""><input class="form-control" type="password" style="margin: auto;width: 100%;margin-top: 10px;" name="password" placeholder="Password" required="" minlength="6"><input class="btn btn-primary" type="submit" style="margin-top: 10px;">
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<script src="assets/bootstrap/js/bootstrap.min.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
Loading…
Reference in New Issue
Block a user