site settings
This commit is contained in:
parent
442355bde7
commit
265a13a601
11 changed files with 116 additions and 109 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -9,6 +9,4 @@ venv/
|
||||||
.vscode/
|
.vscode/
|
||||||
.sass-cache/
|
.sass-cache/
|
||||||
flask_session/
|
flask_session/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
disable_signups
|
|
||||||
fart_mode
|
|
2
env
2
env
|
@ -16,8 +16,6 @@ export SPAM_URL_SIMILARITY_THRESHOLD="0.1"
|
||||||
export SPAM_SIMILAR_COUNT_THRESHOLD="10"
|
export SPAM_SIMILAR_COUNT_THRESHOLD="10"
|
||||||
export COMMENT_SPAM_SIMILAR_THRESHOLD="0.5"
|
export COMMENT_SPAM_SIMILAR_THRESHOLD="0.5"
|
||||||
export COMMENT_SPAM_COUNT_THRESHOLD="10"
|
export COMMENT_SPAM_COUNT_THRESHOLD="10"
|
||||||
export READ_ONLY="0"
|
|
||||||
export BOT_DISABLE="0"
|
|
||||||
export DEFAULT_TIME_FILTER="all"
|
export DEFAULT_TIME_FILTER="all"
|
||||||
export GUMROAD_TOKEN="blahblahblah"
|
export GUMROAD_TOKEN="blahblahblah"
|
||||||
export GUMROAD_LINK="https://marsey1.gumroad.com/l/tfcvri"
|
export GUMROAD_LINK="https://marsey1.gumroad.com/l/tfcvri"
|
||||||
|
|
|
@ -15,11 +15,7 @@ import redis
|
||||||
import time
|
import time
|
||||||
from sys import stdout, argv
|
from sys import stdout, argv
|
||||||
import faulthandler
|
import faulthandler
|
||||||
from json import loads
|
import json
|
||||||
|
|
||||||
for f in (f'files/templates/sidebar_{environ.get("SITE_NAME").strip()}.html', 'disable_signups', 'fart_mode'):
|
|
||||||
if not path.exists(f):
|
|
||||||
with open(f, 'w', encoding="utf-8"): pass
|
|
||||||
|
|
||||||
app = Flask(__name__, template_folder='templates')
|
app = Flask(__name__, template_folder='templates')
|
||||||
app.url_map.strict_slashes = False
|
app.url_map.strict_slashes = False
|
||||||
|
@ -51,8 +47,6 @@ app.config["SPAM_URL_SIMILARITY_THRESHOLD"] = float(environ.get("SPAM_URL_SIMILA
|
||||||
app.config["SPAM_SIMILAR_COUNT_THRESHOLD"] = int(environ.get("SPAM_SIMILAR_COUNT_THRESHOLD", 10))
|
app.config["SPAM_SIMILAR_COUNT_THRESHOLD"] = int(environ.get("SPAM_SIMILAR_COUNT_THRESHOLD", 10))
|
||||||
app.config["COMMENT_SPAM_SIMILAR_THRESHOLD"] = float(environ.get("COMMENT_SPAM_SIMILAR_THRESHOLD", 0.5))
|
app.config["COMMENT_SPAM_SIMILAR_THRESHOLD"] = float(environ.get("COMMENT_SPAM_SIMILAR_THRESHOLD", 0.5))
|
||||||
app.config["COMMENT_SPAM_COUNT_THRESHOLD"] = int(environ.get("COMMENT_SPAM_COUNT_THRESHOLD", 10))
|
app.config["COMMENT_SPAM_COUNT_THRESHOLD"] = int(environ.get("COMMENT_SPAM_COUNT_THRESHOLD", 10))
|
||||||
app.config["READ_ONLY"]=bool(int(environ.get("READ_ONLY", "0")))
|
|
||||||
app.config["BOT_DISABLE"]=bool(int(environ.get("BOT_DISABLE", False)))
|
|
||||||
app.config["CACHE_TYPE"] = "RedisCache"
|
app.config["CACHE_TYPE"] = "RedisCache"
|
||||||
app.config["CACHE_REDIS_URL"] = environ.get("REDIS_URL", "redis://localhost")
|
app.config["CACHE_REDIS_URL"] = environ.get("REDIS_URL", "redis://localhost")
|
||||||
app.config['MAIL_SERVER'] = 'smtp.gmail.com'
|
app.config['MAIL_SERVER'] = 'smtp.gmail.com'
|
||||||
|
@ -61,6 +55,7 @@ app.config['MAIL_USE_TLS'] = True
|
||||||
app.config['MAIL_USERNAME'] = environ.get("MAIL_USERNAME", "").strip()
|
app.config['MAIL_USERNAME'] = environ.get("MAIL_USERNAME", "").strip()
|
||||||
app.config['MAIL_PASSWORD'] = environ.get("MAIL_PASSWORD", "").strip()
|
app.config['MAIL_PASSWORD'] = environ.get("MAIL_PASSWORD", "").strip()
|
||||||
app.config['DESCRIPTION'] = environ.get("DESCRIPTION", "rdrama.net caters to drama in all forms such as: Real life, videos, photos, gossip, rumors, news sites, Reddit, and Beyond™. There isn't drama we won't touch, and we want it all!").strip()
|
app.config['DESCRIPTION'] = environ.get("DESCRIPTION", "rdrama.net caters to drama in all forms such as: Real life, videos, photos, gossip, rumors, news sites, Reddit, and Beyond™. There isn't drama we won't touch, and we want it all!").strip()
|
||||||
|
app.config['SETTINGS'] = {}
|
||||||
|
|
||||||
r=redis.Redis(host=environ.get("REDIS_URL", "redis://localhost"), decode_responses=True, ssl_cert_reqs=None)
|
r=redis.Redis(host=environ.get("REDIS_URL", "redis://localhost"), decode_responses=True, ssl_cert_reqs=None)
|
||||||
|
|
||||||
|
@ -88,13 +83,14 @@ mail = Mail(app)
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def before_request():
|
def before_request():
|
||||||
|
|
||||||
|
with open('site_settings.json', 'r') as f:
|
||||||
|
app.config['SETTINGS'] = json.load(f)
|
||||||
|
|
||||||
if request.host != app.config["SERVER_NAME"]: return {"error":"Unauthorized host provided."}, 401
|
if request.host != app.config["SERVER_NAME"]: return {"error":"Unauthorized host provided."}, 401
|
||||||
if request.headers.get("CF-Worker"): return {"error":"Cloudflare workers are not allowed to access this website."}, 401
|
if request.headers.get("CF-Worker"): return {"error":"Cloudflare workers are not allowed to access this website."}, 401
|
||||||
|
|
||||||
if request.method.lower() != "get" and app.config["READ_ONLY"]:
|
if not app.config['SETTINGS']['Bots'] and request.headers.get("Authorization"): abort(503)
|
||||||
return {"error":f"{app.config['SITE_NAME']} is currently in read-only mode."}, 500
|
|
||||||
|
|
||||||
if app.config["BOT_DISABLE"] and request.headers.get("Authorization"): abort(503)
|
|
||||||
|
|
||||||
g.db = db_session()
|
g.db = db_session()
|
||||||
|
|
||||||
|
|
|
@ -175,13 +175,23 @@ ACTIONTYPES = {
|
||||||
"icon": 'fa-flag',
|
"icon": 'fa-flag',
|
||||||
"color": 'bg-danger'
|
"color": 'bg-danger'
|
||||||
},
|
},
|
||||||
'disable_fart_mode': {
|
'disable_Bots': {
|
||||||
|
"str": 'disabled Bots',
|
||||||
|
"icon": 'fa-robot',
|
||||||
|
"color": 'bg-danger'
|
||||||
|
},
|
||||||
|
'disable_Fart mode': {
|
||||||
"str": 'disabled fart mode',
|
"str": 'disabled fart mode',
|
||||||
"icon": 'fa-gas-pump-slash',
|
"icon": 'fa-gas-pump-slash',
|
||||||
"color": 'bg-danger'
|
"color": 'bg-danger'
|
||||||
},
|
},
|
||||||
'disable_signups': {
|
'disable_Readonly mode': {
|
||||||
"str": 'disabled signups',
|
"str": 'disabled readonly mode',
|
||||||
|
"icon": 'fa-book',
|
||||||
|
"color": 'bg-danger'
|
||||||
|
},
|
||||||
|
'disable_Signups': {
|
||||||
|
"str": 'disabled Signups',
|
||||||
"icon": 'fa-users',
|
"icon": 'fa-users',
|
||||||
"color": 'bg-danger'
|
"color": 'bg-danger'
|
||||||
},
|
},
|
||||||
|
@ -215,13 +225,23 @@ ACTIONTYPES = {
|
||||||
"icon": 'fa-edit',
|
"icon": 'fa-edit',
|
||||||
"color": 'bg-primary'
|
"color": 'bg-primary'
|
||||||
},
|
},
|
||||||
'enable_fart_mode': {
|
'enable_Bots': {
|
||||||
|
"str": 'enabled Bots',
|
||||||
|
"icon": 'fa-robot',
|
||||||
|
"color": 'bg-success'
|
||||||
|
},
|
||||||
|
'enable_Fart mode': {
|
||||||
"str": 'enabled fart mode',
|
"str": 'enabled fart mode',
|
||||||
"icon": 'fa-gas-pump',
|
"icon": 'fa-gas-pump',
|
||||||
"color": 'bg-success'
|
"color": 'bg-success'
|
||||||
},
|
},
|
||||||
'enable_signups': {
|
'enable_Readonly mode': {
|
||||||
"str": 'enabled signups',
|
"str": 'enabled readonly mode',
|
||||||
|
"icon": 'fa-book',
|
||||||
|
"color": 'bg-success'
|
||||||
|
},
|
||||||
|
'enable_Signups': {
|
||||||
|
"str": 'enabled Signups',
|
||||||
"icon": 'fa-users',
|
"icon": 'fa-users',
|
||||||
"color": 'bg-success'
|
"color": 'bg-success'
|
||||||
},
|
},
|
||||||
|
|
|
@ -7,30 +7,32 @@ from random import randint
|
||||||
def get_logged_in_user():
|
def get_logged_in_user():
|
||||||
if not (hasattr(g, 'db') and g.db): g.db = db_session()
|
if not (hasattr(g, 'db') and g.db): g.db = db_session()
|
||||||
|
|
||||||
|
v = None
|
||||||
|
|
||||||
token = request.headers.get("Authorization","").strip()
|
token = request.headers.get("Authorization","").strip()
|
||||||
if token:
|
if token:
|
||||||
client = g.db.query(ClientAuth).filter(ClientAuth.access_token == token).one_or_none()
|
client = g.db.query(ClientAuth).filter(ClientAuth.access_token == token).one_or_none()
|
||||||
|
if client:
|
||||||
if not client: return None
|
v = client.user
|
||||||
|
v.client = client
|
||||||
v = client.user
|
|
||||||
v.client = client
|
|
||||||
else:
|
else:
|
||||||
lo_user = session.get("lo_user")
|
lo_user = session.get("lo_user")
|
||||||
if not lo_user: return None
|
if lo_user:
|
||||||
|
nonce = session.get("login_nonce", 0)
|
||||||
|
id = int(lo_user)
|
||||||
|
v = g.db.query(User).filter_by(id=id).one_or_none()
|
||||||
|
if v and nonce >= v.login_nonce:
|
||||||
|
if v.id != id: abort(400)
|
||||||
|
v.client = None
|
||||||
|
|
||||||
nonce = session.get("login_nonce", 0)
|
if request.method != "GET":
|
||||||
id = int(lo_user)
|
submitted_key = request.values.get("formkey")
|
||||||
v = g.db.query(User).filter_by(id=id).one_or_none()
|
if not submitted_key: abort(401)
|
||||||
if not v or nonce < v.login_nonce: return None
|
elif not v.validate_formkey(submitted_key): abort(401)
|
||||||
|
|
||||||
if v.id != id: abort(400)
|
|
||||||
v.client = None
|
|
||||||
|
|
||||||
if request.method != "GET":
|
if request.method.lower() != "get" and app.config['SETTINGS']['Readonly mode'] and not (v and v.admin_level):
|
||||||
submitted_key = request.values.get("formkey")
|
abort(403)
|
||||||
if not submitted_key: abort(401)
|
|
||||||
elif not v.validate_formkey(submitted_key): abort(401)
|
|
||||||
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
|
@ -521,66 +521,53 @@ def reported_comments(v):
|
||||||
@app.get("/admin")
|
@app.get("/admin")
|
||||||
@admin_level_required(2)
|
@admin_level_required(2)
|
||||||
def admin_home(v):
|
def admin_home(v):
|
||||||
with open('disable_signups', 'r') as f: x = f.read()
|
|
||||||
|
|
||||||
if CF_ZONE == 'blahblahblah': response = 'high'
|
if CF_ZONE == 'blahblahblah': response = 'high'
|
||||||
else: response = requests.get(f'https://api.cloudflare.com/client/v4/zones/{CF_ZONE}/settings/security_level', headers=CF_HEADERS, timeout=5).json()['result']['value']
|
else: response = requests.get(f'https://api.cloudflare.com/client/v4/zones/{CF_ZONE}/settings/security_level', headers=CF_HEADERS, timeout=5).json()['result']['value']
|
||||||
|
under_attack = response == 'under_attack'
|
||||||
x2 = response == 'under_attack'
|
|
||||||
|
|
||||||
with open('fart_mode', 'r') as f: x3 = f.read()
|
return render_template("admin/admin_home.html", v=v, under_attack=under_attack, site_settings=app.config['SETTINGS'])
|
||||||
|
|
||||||
return render_template("admin/admin_home.html", v=v, x=x, x2=x2, x3=x3)
|
|
||||||
|
|
||||||
@app.post("/admin/disable_signups")
|
@app.post("/admin/site_settings/<setting>")
|
||||||
@admin_level_required(3)
|
@admin_level_required(3)
|
||||||
def disable_signups(v):
|
def change_settings(v, setting):
|
||||||
with open('disable_signups', 'r') as f: content = f.read()
|
site_settings = app.config['SETTINGS']
|
||||||
|
site_settings[setting] = not site_settings[setting]
|
||||||
|
with open("site_settings.json", "w") as f:
|
||||||
|
json.dump(site_settings, f)
|
||||||
|
|
||||||
with open('disable_signups', 'w') as f:
|
if site_settings[setting]: word = 'enable'
|
||||||
if content == "yes":
|
else: word = 'disable'
|
||||||
f.write("no")
|
|
||||||
ma = ModAction(
|
|
||||||
kind="enable_signups",
|
|
||||||
user_id=v.id,
|
|
||||||
)
|
|
||||||
g.db.add(ma)
|
|
||||||
g.db.commit()
|
|
||||||
return {"message": "Signups enabled!"}
|
|
||||||
else:
|
|
||||||
f.write("yes")
|
|
||||||
ma = ModAction(
|
|
||||||
kind="disable_signups",
|
|
||||||
user_id=v.id,
|
|
||||||
)
|
|
||||||
g.db.add(ma)
|
|
||||||
g.db.commit()
|
|
||||||
return {"message": "Signups disabled!"}
|
|
||||||
|
|
||||||
@app.post("/admin/fart_mode")
|
body = f"@{v.username} has {word}d `{setting}` in the [admin dashboard](/admin)!"
|
||||||
@admin_level_required(3)
|
|
||||||
def fart_mode(v):
|
|
||||||
with open('fart_mode', 'r') as f: content = f.read()
|
|
||||||
|
|
||||||
with open('fart_mode', 'w') as f:
|
body_html = sanitize(body, noimages=True)
|
||||||
if content == "yes":
|
|
||||||
f.write("no")
|
new_comment = Comment(author_id=NOTIFICATIONS_ID,
|
||||||
ma = ModAction(
|
parent_submission=None,
|
||||||
kind="enable_fart_mode",
|
level=1,
|
||||||
user_id=v.id,
|
body_html=body_html,
|
||||||
)
|
sentto=2,
|
||||||
g.db.add(ma)
|
distinguish_level=6
|
||||||
g.db.commit()
|
)
|
||||||
return {"message": "Fart mode disabled!"}
|
g.db.add(new_comment)
|
||||||
else:
|
g.db.flush()
|
||||||
f.write("yes")
|
|
||||||
ma = ModAction(
|
new_comment.top_comment_id = new_comment.id
|
||||||
kind="disable_fart_mode",
|
|
||||||
user_id=v.id,
|
for admin in g.db.query(User).filter(User.admin_level > 2, User.id != v.id).all():
|
||||||
)
|
notif = Notification(comment_id=new_comment.id, user_id=admin.id)
|
||||||
g.db.add(ma)
|
g.db.add(notif)
|
||||||
g.db.commit()
|
|
||||||
return {"message": "Fart mode enabled!"}
|
ma = ModAction(
|
||||||
|
kind=f"{word}_{setting}",
|
||||||
|
user_id=v.id,
|
||||||
|
)
|
||||||
|
g.db.add(ma)
|
||||||
|
|
||||||
|
g.db.commit()
|
||||||
|
|
||||||
|
return {'message': f"{setting} {word}d successfully!"}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/admin/purge_cache")
|
@app.post("/admin/purge_cache")
|
||||||
|
|
|
@ -179,9 +179,8 @@ def logout(v):
|
||||||
@app.get("/signup")
|
@app.get("/signup")
|
||||||
@auth_desired
|
@auth_desired
|
||||||
def sign_up_get(v):
|
def sign_up_get(v):
|
||||||
with open('disable_signups', 'r') as f:
|
if not app.config['SETTINGS']['Signups']:
|
||||||
if f.read() == "yes":
|
return {"error": "New account registration is currently closed. Please come back later."}, 403
|
||||||
return {"error": "New account registration is currently closed. Please come back later."}, 403
|
|
||||||
|
|
||||||
if v: return redirect(SITE_FULL)
|
if v: return redirect(SITE_FULL)
|
||||||
|
|
||||||
|
@ -226,9 +225,8 @@ def sign_up_get(v):
|
||||||
@limiter.limit("10/day")
|
@limiter.limit("10/day")
|
||||||
@auth_desired
|
@auth_desired
|
||||||
def sign_up_post(v):
|
def sign_up_post(v):
|
||||||
with open('disable_signups', 'r') as f:
|
if not app.config['SETTINGS']['Signups']:
|
||||||
if f.read() == "yes":
|
return {"error": "New account registration is currently closed. Please come back later."}, 403
|
||||||
return {"error": "New account registration is currently closed. Please come back later."}, 403
|
|
||||||
|
|
||||||
if v: abort(403)
|
if v: abort(403)
|
||||||
|
|
||||||
|
|
|
@ -52,7 +52,7 @@ def request_api_keys(v):
|
||||||
|
|
||||||
g.db.add(new_app)
|
g.db.add(new_app)
|
||||||
|
|
||||||
body = f"{v.username} has requested API keys for `{request.values.get('name')}`. You can approve or deny the request [here](/admin/apps)."
|
body = f"@{v.username} has requested API keys for `{request.values.get('name')}`. You can approve or deny the request [here](/admin/apps)."
|
||||||
|
|
||||||
body_html = sanitize(body, noimages=True)
|
body_html = sanitize(body, noimages=True)
|
||||||
|
|
||||||
|
|
|
@ -253,13 +253,9 @@ def post_id(pid, anything=None, v=None, sub=None):
|
||||||
g.db.commit()
|
g.db.commit()
|
||||||
if request.headers.get("Authorization"): return post.json
|
if request.headers.get("Authorization"): return post.json
|
||||||
else:
|
else:
|
||||||
with open('fart_mode', 'r') as f:
|
|
||||||
if f.read() == "yes": fart = True
|
|
||||||
else: fart = False
|
|
||||||
|
|
||||||
if post.is_banned and not (v and (v.admin_level > 1 or post.author_id == v.id)): template = "submission_banned.html"
|
if post.is_banned and not (v and (v.admin_level > 1 or post.author_id == v.id)): template = "submission_banned.html"
|
||||||
else: template = "submission.html"
|
else: template = "submission.html"
|
||||||
return render_template(template, v=v, p=post, ids=list(ids), sort=sort, render_replies=True, offset=offset, sub=post.subr, fart=fart)
|
return render_template(template, v=v, p=post, ids=list(ids), sort=sort, render_replies=True, offset=offset, sub=post.subr, fart=app.config['SETTINGS']['Fart mode'])
|
||||||
|
|
||||||
@app.get("/viewmore/<pid>/<sort>/<offset>")
|
@app.get("/viewmore/<pid>/<sort>/<offset>")
|
||||||
@limiter.limit("1/second;30/minute;200/hour;1000/day")
|
@limiter.limit("1/second;30/minute;200/hour;1000/day")
|
||||||
|
|
|
@ -58,21 +58,32 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if v.admin_level > 2 %}
|
{% if v.admin_level > 2 %}
|
||||||
|
|
||||||
<div class="custom-control custom-switch">
|
<div class="custom-control custom-switch">
|
||||||
<input autocomplete="off" type="checkbox" class="custom-control-input" id="disable_signups" name="disable_signups" {% if x == "yes" %}checked{% endif %} onchange="post_toast(this,'/admin/disable_signups');">
|
<input autocomplete="off" type="checkbox" class="custom-control-input" id="signups" {% if site_settings['Signups'] %}checked{% endif %} onchange="post_toast(this,'/admin/site_settings/Signups');">
|
||||||
<label class="custom-control-label" for="disable_signups">Disable signups</label>
|
<label class="custom-control-label" for="signups">Signups</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="custom-control custom-switch">
|
<div class="custom-control custom-switch">
|
||||||
<input autocomplete="off" type="checkbox" class="custom-control-input" id="under_attack" name="under_attack" {% if x2 %}checked{% endif %} onchange="post_toast(this,'/admin/under_attack');">
|
<input autocomplete="off" type="checkbox" class="custom-control-input" id="bots" {% if site_settings['Bots'] %}checked{% endif %} onchange="post_toast(this,'/admin/site_settings/Bots');">
|
||||||
|
<label class="custom-control-label" for="bots">Bots</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="custom-control custom-switch">
|
||||||
|
<input autocomplete="off" type="checkbox" class="custom-control-input" id="Fart mode" {% if site_settings['Fart mode'] %}checked{% endif %} onchange="post_toast(this,'/admin/site_settings/Fart mode');">
|
||||||
|
<label class="custom-control-label" for="Fart mode">Fart mode</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="custom-control custom-switch">
|
||||||
|
<input autocomplete="off" type="checkbox" class="custom-control-input" id="Readonly mode" {% if site_settings['Readonly mode'] %}checked{% endif %} onchange="post_toast(this,'/admin/site_settings/Readonly mode');">
|
||||||
|
<label class="custom-control-label" for="Readonly mode">Readonly mode</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="custom-control custom-switch">
|
||||||
|
<input autocomplete="off" type="checkbox" class="custom-control-input" id="under_attack" name="under_attack" {% if under_attack%}checked{% endif %} onchange="post_toast(this,'/admin/under_attack');">
|
||||||
<label class="custom-control-label" for="under_attack">Under attack mode</label>
|
<label class="custom-control-label" for="under_attack">Under attack mode</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="custom-control custom-switch">
|
|
||||||
<input autocomplete="off" type="checkbox" class="custom-control-input" id="fart_mode" name="fart_mode" {% if x3 == "yes" %}checked{% endif %} onchange="post_toast(this,'/admin/fart_mode');">
|
|
||||||
<label class="custom-control-label" for="fart_mode">Fart mode</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="btn btn-primary mt-3" onclick="post_toast(this,'/admin/purge_cache');">PURGE CACHE</button>
|
<button class="btn btn-primary mt-3" onclick="post_toast(this,'/admin/purge_cache');">PURGE CACHE</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
1
site_settings.json
Normal file
1
site_settings.json
Normal file
|
@ -0,0 +1 @@
|
||||||
|
{"Bots": true, "Fart mode": false, "Readonly mode": false, "Signups": true}
|
Loading…
Add table
Add a link
Reference in a new issue