initial commit
This commit is contained in:
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN ln -sf /usr/local/bin/python3 /usr/bin/python3
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY . /app/
|
||||||
|
RUN mkdir -p /app/cgi-bin \
|
||||||
|
&& cp /app/*.py /app/*.cgi /app/cgi-bin/ \
|
||||||
|
&& cp /app/words.txt /app/cgi-bin/ \
|
||||||
|
&& cp /app/*.html /app/cgi-bin/
|
||||||
|
|
||||||
|
RUN chmod +x /app/index.cgi
|
||||||
|
RUN chmod +x /app/cgi-bin/*.py /app/cgi-bin/*.cgi || true
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["python", "-m", "http.server", "8000", "--cgi"]
|
||||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Code from https://untroubled.org/pwgen/ppgen.cgi modified to work as a docker container.
|
||||||
|
|
||||||
|
Wordlist from https://raw.githubusercontent.com/dwyl/english-words/master/words_alpha.txt
|
||||||
69
common.py
Normal file
69
common.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import cgitb
|
||||||
|
cgitb.enable()
|
||||||
|
|
||||||
|
import cgi
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def bool(s):
|
||||||
|
try: return int(s)
|
||||||
|
except ValueError: pass
|
||||||
|
return s == 'on'
|
||||||
|
|
||||||
|
def commafy(s):
|
||||||
|
s = str(int(s))
|
||||||
|
o = ''
|
||||||
|
while len(s) > 3:
|
||||||
|
o = ',' + s[-3:] + o
|
||||||
|
s = s[:-3]
|
||||||
|
return s + o
|
||||||
|
|
||||||
|
def escape(s):
|
||||||
|
return str(s).replace('&', '&').replace('<', '<').replace('>', '>')
|
||||||
|
|
||||||
|
def form_get(form, name, default, conv=str):
|
||||||
|
try:
|
||||||
|
item = form[name]
|
||||||
|
except KeyError:
|
||||||
|
return default
|
||||||
|
if item.file:
|
||||||
|
print("Uploads are not allowed!")
|
||||||
|
sys.exit(1)
|
||||||
|
if type(item.value) is not type(''):
|
||||||
|
print("Multiple values are not allowed!")
|
||||||
|
sys.exit(1)
|
||||||
|
try:
|
||||||
|
value = conv(item.value)
|
||||||
|
except:
|
||||||
|
print("'%s' failed to convert" % name)
|
||||||
|
sys.exit(1)
|
||||||
|
return value
|
||||||
|
|
||||||
|
class EvalDict:
|
||||||
|
def __init__(self, values):
|
||||||
|
self.values = values
|
||||||
|
def __getitem__(self, key):
|
||||||
|
return escape(eval(key, self.values))
|
||||||
|
|
||||||
|
def row(a,b,bold=0):
|
||||||
|
pre = post = ''
|
||||||
|
if bold:
|
||||||
|
pre = '<b>'
|
||||||
|
post = '</b>'
|
||||||
|
print('<tr><th align=right>%s:</th><td><tt>%s%s%s</tt></td></tr>' % (
|
||||||
|
a, pre, b, post ))
|
||||||
|
def table_start(): print('<hr><div align=center><table border=1>')
|
||||||
|
def table_end(): print('</table></div>')
|
||||||
|
|
||||||
|
def dumpfile(filename, dict=None):
|
||||||
|
# Always resolve relative to this file's directory
|
||||||
|
here = os.path.dirname(__file__)
|
||||||
|
path = os.path.join(here, filename)
|
||||||
|
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
data = f.read()
|
||||||
|
|
||||||
|
if dict is not None:
|
||||||
|
data = data % dict
|
||||||
|
|
||||||
|
sys.stdout.write(data)
|
||||||
20
footer.html
Normal file
20
footer.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<hr />
|
||||||
|
|
||||||
|
<p>Notes: <ul>
|
||||||
|
|
||||||
|
<li>No data generated by this page is stored on the server at any point.
|
||||||
|
In other words, we aren't recording your passwords.</li>
|
||||||
|
|
||||||
|
<li>The data used to generate the passwords is derived from Linux's
|
||||||
|
/dev/urandom secure data source, and is carefully masked to prevent
|
||||||
|
biasing or truncation.</li>
|
||||||
|
|
||||||
|
<li>Unless this form is accessed via a secure session (ie HTTPS or a
|
||||||
|
tunnel), the password will be susceptible to being sniffed as it is
|
||||||
|
being transmitted. Use the appropriate precautions.</li>
|
||||||
|
|
||||||
|
</ul></p>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
6
header.html
Normal file
6
header.html
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Secure %(type)s Generator</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div align=center><h1>Secure %(type)s Generator</h1></div><hr>
|
||||||
5
index.cgi
Executable file
5
index.cgi
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
print("Content-Type: text/html")
|
||||||
|
print("Status: 302 Found")
|
||||||
|
print("Location: /cgi-bin/ppgen.py")
|
||||||
|
print()
|
||||||
10
index.html
Normal file
10
index.html
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="refresh" content="0; url=/cgi-bin/ppgen.cgi">
|
||||||
|
<title>Secure Passphrase Generator</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>Redirecting to <a href="/cgi-bin/ppgen.cgi">Secure Passphrase Generator</a>…</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
21
longrandom.py
Normal file
21
longrandom.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import math, os
|
||||||
|
from functools import reduce
|
||||||
|
|
||||||
|
log256 = math.log(256)
|
||||||
|
|
||||||
|
class LongRandom:
|
||||||
|
def getint(self, bytes):
|
||||||
|
value = list(os.urandom(bytes))
|
||||||
|
return reduce(lambda x,y: (x<<8)|y, value)
|
||||||
|
def get(self, modulus):
|
||||||
|
# Find the largest power-of-2 that modulus fits into
|
||||||
|
bytes = int(math.ceil(math.log(modulus) / log256))
|
||||||
|
maxval = 256 ** bytes
|
||||||
|
# maxmod is the largest multiple of modulus not greater than maxval
|
||||||
|
maxmult = maxval - maxval % modulus
|
||||||
|
while True:
|
||||||
|
value = self.getint(bytes)
|
||||||
|
# Stop generating when the value would not cause bias
|
||||||
|
if value <= maxmult:
|
||||||
|
break
|
||||||
|
return value % modulus
|
||||||
16
makedict
Executable file
16
makedict
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
words = [ ]
|
||||||
|
for filename in sys.argv[1:]:
|
||||||
|
for line in open(filename):
|
||||||
|
line = line.strip().lower()
|
||||||
|
try:
|
||||||
|
words[len(line)][line] = None
|
||||||
|
except IndexError:
|
||||||
|
while len(words) <= len(line):
|
||||||
|
words.append({})
|
||||||
|
for i in range(len(words)):
|
||||||
|
words[i] = sorted(words[i].keys())
|
||||||
|
|
||||||
|
print('words =', words)
|
||||||
33
ppform.html
Normal file
33
ppform.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<form method=get action="ppgen.cgi">
|
||||||
|
<div align=center>
|
||||||
|
<table border=0>
|
||||||
|
<tr>
|
||||||
|
<td>Number of words:</td>
|
||||||
|
<td><input name="wordcount" value="%(wordcount)s" size="2" /></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Minimum word length:</td>
|
||||||
|
<td><input name="minlen" value="%(minlen)s" size="2" /> characters</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Maximum word length:</td>
|
||||||
|
<td><input name="maxlen" value="%(maxlen)s" size="2" /> characters</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Random capitalization:</td>
|
||||||
|
<td><select name="randcaps">
|
||||||
|
<option value="none" %(randcaps=='none' and 'selected' or '')s>No capitalization</option>
|
||||||
|
<option value="first" %(randcaps=='first' and 'selected' or '')s>Randomly capitalize the first letter</option>
|
||||||
|
<option value="one" %(randcaps=='one' and 'selected' or '')s>Randomly capitalize any one letter</option>
|
||||||
|
<option value="all" %(randcaps=='all' and 'selected' or '')s>Randomly capitalize all letters</option>
|
||||||
|
</select></td>
|
||||||
|
<tr>
|
||||||
|
<td>Length of numbers between words:</td>
|
||||||
|
<td><input name="numlen" value="%(numlen)s" size="2" /> digits</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<input type=submit name=submit value="Generate Passphrase"><input type=reset>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p align="center">[ <a href="pwgen.cgi">Click here for the password generator</a> ]</p>
|
||||||
12
ppgen.cgi
Executable file
12
ppgen.cgi
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
import runpy
|
||||||
|
|
||||||
|
# Directory this CGI script lives in
|
||||||
|
here = os.path.dirname(__file__)
|
||||||
|
|
||||||
|
# Path to the real script
|
||||||
|
script = os.path.join(here, "ppgen.py")
|
||||||
|
|
||||||
|
# Execute ppgen.py as if it were the main script
|
||||||
|
runpy.run_path(script, run_name="__main__")
|
||||||
102
ppgen.py
Executable file
102
ppgen.py
Executable file
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
from math import *
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
from common import *
|
||||||
|
from longrandom import LongRandom
|
||||||
|
from worddict import words
|
||||||
|
|
||||||
|
print("Content-Type: text/html")
|
||||||
|
print()
|
||||||
|
|
||||||
|
qs = os.environ.get('QUERY_STRING', None)
|
||||||
|
if not qs:
|
||||||
|
minlen = 1
|
||||||
|
maxlen = 8
|
||||||
|
numlen = 2
|
||||||
|
wordcount = 3
|
||||||
|
extra_symbols = ''
|
||||||
|
randcaps = 'first'
|
||||||
|
else:
|
||||||
|
form = cgi.FieldStorage()
|
||||||
|
wordcount = form_get(form, 'wordcount', 3, int)
|
||||||
|
minlen = form_get(form, 'minlen', 2, int)
|
||||||
|
maxlen = form_get(form, 'maxlen', 8, int)
|
||||||
|
numlen = form_get(form, 'numlen', 2, int)
|
||||||
|
randcaps = form_get(form, 'randcaps', 'first', str)
|
||||||
|
|
||||||
|
dumpfile('header.html', {'type':'Passphrase'})
|
||||||
|
dict = EvalDict(vars())
|
||||||
|
here = os.path.dirname(__file__)
|
||||||
|
form_path = os.path.join(here, 'ppform.html')
|
||||||
|
with open(form_path, encoding="utf-8") as f:
|
||||||
|
sys.stdout.write(f.read() % dict)
|
||||||
|
|
||||||
|
usewords = [ ]
|
||||||
|
totalen = 0
|
||||||
|
for i in range(minlen, maxlen+1):
|
||||||
|
totalen += len(words[i]) * i
|
||||||
|
usewords.extend(words[i])
|
||||||
|
|
||||||
|
log_2 = log(2)
|
||||||
|
wordbits = log(len(usewords)) / log_2
|
||||||
|
medlen = len(usewords[len(usewords)//2])
|
||||||
|
avglen = totalen / len(usewords)
|
||||||
|
if randcaps == 'first':
|
||||||
|
capbits = 1
|
||||||
|
capmult = 2
|
||||||
|
elif randcaps == 'one':
|
||||||
|
# Use estimate based on average word length
|
||||||
|
capbits = log(avglen+1) / log_2
|
||||||
|
capmult = avglen+1
|
||||||
|
elif randcaps == 'all':
|
||||||
|
# One bit per average word length
|
||||||
|
capbits = avglen
|
||||||
|
capmult = 2**avglen
|
||||||
|
else:
|
||||||
|
capbits = 0
|
||||||
|
capmult = 1
|
||||||
|
numbits = log(10) * numlen / log_2
|
||||||
|
numfmt = '{{:0{}}}'.format(numlen)
|
||||||
|
passbits = (wordbits + capbits) * wordcount + numbits * ((wordcount - 1) or 1)
|
||||||
|
|
||||||
|
table_start()
|
||||||
|
row('Word count', commafy(len(usewords)))
|
||||||
|
row('Average word length', '%.2f' % avglen)
|
||||||
|
row('Bits per word', '%.2f' % wordbits)
|
||||||
|
row('Bits per number', '%.2f' % numbits)
|
||||||
|
row('Bits for capitalization', '%.2f' % (capbits * wordcount))
|
||||||
|
row('Effective passphrase bits', int(passbits))
|
||||||
|
row('Total possible combinations',
|
||||||
|
commafy(len(usewords)**wordcount * (10**numlen)**((wordcount-1) or 1) * capmult**wordcount))
|
||||||
|
table_end()
|
||||||
|
|
||||||
|
randval = LongRandom()
|
||||||
|
|
||||||
|
table_start()
|
||||||
|
print("<tr><th>Passphrase</th></tr>")
|
||||||
|
for i in range(10):
|
||||||
|
passphrase = ''
|
||||||
|
for j in range(wordcount):
|
||||||
|
word = usewords[randval.get(len(usewords))]
|
||||||
|
if randcaps == 'first':
|
||||||
|
if randval.get(2):
|
||||||
|
word = word[0].upper() + word[1:]
|
||||||
|
elif randcaps == 'one':
|
||||||
|
k = randval.get(len(word)+1)
|
||||||
|
if k < len(word):
|
||||||
|
word = word[:k] + word[k].upper() + word[k+1:]
|
||||||
|
elif randcaps == 'all':
|
||||||
|
word = ''.join([ randval.get(2) and ch.upper() or ch.lower()
|
||||||
|
for ch in word ])
|
||||||
|
passphrase += word
|
||||||
|
if numlen > 0 and (j < wordcount-1 or wordcount == 1):
|
||||||
|
passphrase += numfmt.format(randval.get(10 ** numlen))
|
||||||
|
|
||||||
|
print("<tr><td><tt>%s</tt></td></tr>" % escape(passphrase))
|
||||||
|
|
||||||
|
table_end()
|
||||||
|
dumpfile('footer.html')
|
||||||
25
pwform.html
Normal file
25
pwform.html
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<form method=get action="pwgen.cgi">
|
||||||
|
<div align=center>
|
||||||
|
<table border=0>
|
||||||
|
<tr>
|
||||||
|
<td>Password Length:</td>
|
||||||
|
<td><input name=passlen value="%(passlen)s" size="2" /><select name=length_is_bits>
|
||||||
|
<option value=0 %(length_is_bits==0 and 'selected' or '')s>Characters
|
||||||
|
<option value=1 %(length_is_bits!=0 and 'selected' or '')s>Bits
|
||||||
|
</select></td>
|
||||||
|
</tr>
|
||||||
|
<tr><td colspan=2><input type=checkbox name=use_ucase value=1 %(use_ucase and 'checked' or '')s>Use upper-case Letters</td></tr>
|
||||||
|
<tr><td colspan=2><input type=checkbox name=use_lcase value=1 %(use_lcase and 'checked' or '')s>Use lower-case Letters</td></tr>
|
||||||
|
<tr><td colspan=2><input type=checkbox name=use_digits value=1 %(use_digits and 'checked' or '')s>Use digits</td></tr>
|
||||||
|
<tr><td colspan=2><input type=checkbox name=use_punct value=1 %(use_punct and 'checked' or '')s>Use all punctuation</td></tr>
|
||||||
|
<tr><td colspan=2><input type=checkbox name=excludes value=1 %(excludes and 'checked' or '')s>Exclude 1/l/I and 0/O</td></tr>
|
||||||
|
<tr>
|
||||||
|
<td>Extra Symbols:</td>
|
||||||
|
<td><input name=extra_symbols value="%(extra_symbols)s"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<input type=submit name=submit value="Generate Password"><input type=reset>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p align="center">[ <a href="ppgen.cgi">Click here for the passphrase generator</a> ]</p>
|
||||||
12
pwgen.cgi
Executable file
12
pwgen.cgi
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
import runpy
|
||||||
|
|
||||||
|
# Directory this CGI script lives in
|
||||||
|
here = os.path.dirname(__file__)
|
||||||
|
|
||||||
|
# Path to the real script
|
||||||
|
script = os.path.join(here, "pwgen.py")
|
||||||
|
|
||||||
|
# Execute ppgen.py as if it were the main script
|
||||||
|
runpy.run_path(script, run_name="__main__")
|
||||||
74
pwgen.py
Executable file
74
pwgen.py
Executable file
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
from math import *
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
from common import *
|
||||||
|
from longrandom import LongRandom
|
||||||
|
|
||||||
|
print("Content-Type: text/html")
|
||||||
|
print()
|
||||||
|
|
||||||
|
qs = os.environ.get('QUERY_STRING', None)
|
||||||
|
if not qs:
|
||||||
|
use_lcase = use_digits = 1
|
||||||
|
use_punct = use_ucase = 0
|
||||||
|
passlen = 64
|
||||||
|
length_is_bits = 1
|
||||||
|
extra_symbols = ''
|
||||||
|
excludes = True
|
||||||
|
else:
|
||||||
|
form = cgi.FieldStorage()
|
||||||
|
passlen = form_get(form, 'passlen', 12, int)
|
||||||
|
length_is_bits = form_get(form, 'length_is_bits', 0, int)
|
||||||
|
use_ucase = form_get(form, 'use_ucase', 0, int)
|
||||||
|
use_lcase = form_get(form, 'use_lcase', 0, int)
|
||||||
|
use_digits = form_get(form, 'use_digits', 0, int)
|
||||||
|
use_punct = form_get(form, 'use_punct', 0, int)
|
||||||
|
extra_symbols = form_get(form, 'extra_symbols', '', str)
|
||||||
|
excludes = form_get(form, 'excludes', 0, int)
|
||||||
|
|
||||||
|
dumpfile('header.html', {'type': 'Password'})
|
||||||
|
dict = EvalDict(vars())
|
||||||
|
here = os.path.dirname(__file__)
|
||||||
|
form_path = os.path.join(here, 'pwform.html')
|
||||||
|
with open(form_path, encoding="utf-8") as f:
|
||||||
|
sys.stdout.write(f.read() % dict)
|
||||||
|
|
||||||
|
|
||||||
|
chars = extra_symbols
|
||||||
|
if use_lcase: chars += 'abcdefghijklmnopqrstuvwxyz'
|
||||||
|
if use_ucase: chars += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||||
|
if use_digits: chars += '0123456789'
|
||||||
|
if use_punct: chars += '~`!@#$%^&*()-_=+[]{}\\|;:,./<>?\'"'
|
||||||
|
if excludes:
|
||||||
|
for char in '1lI0O':
|
||||||
|
chars = chars.replace(char, '')
|
||||||
|
|
||||||
|
charbits = log(len(chars)) / log(2)
|
||||||
|
if length_is_bits:
|
||||||
|
passlen = int(passlen // charbits + 1)
|
||||||
|
passbits = passlen * charbits
|
||||||
|
|
||||||
|
table_start()
|
||||||
|
row('Password Length', passlen)
|
||||||
|
row('Bits per character', '%.2f'%charbits)
|
||||||
|
row('Effective password bits', int(passbits))
|
||||||
|
row('Total possible combinations',
|
||||||
|
commafy(len(chars) ** passlen))
|
||||||
|
table_end()
|
||||||
|
|
||||||
|
randval = LongRandom()
|
||||||
|
|
||||||
|
table_start()
|
||||||
|
print("<tr><th>Password</th></tr>")
|
||||||
|
for i in range(10):
|
||||||
|
password = ''.join([ chars[randval.get(len(chars))]
|
||||||
|
for j in range(passlen) ])
|
||||||
|
|
||||||
|
print("<tr><td><tt>%s</tt></td></tr>" % escape(password))
|
||||||
|
|
||||||
|
table_end()
|
||||||
|
dumpfile('footer.html')
|
||||||
37
worddict.py
Normal file
37
worddict.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# worddict.py
|
||||||
|
#
|
||||||
|
# Builds:
|
||||||
|
# words[length] = [word1, word2, ...]
|
||||||
|
#
|
||||||
|
# Designed for ppgen.py and the dwyl words_alpha.txt word list.
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
WORDLIST_PATH = os.path.join(os.path.dirname(__file__), "words.txt")
|
||||||
|
|
||||||
|
words = defaultdict(list)
|
||||||
|
|
||||||
|
def load_wordlist():
|
||||||
|
if not os.path.exists(WORDLIST_PATH):
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Missing wordlist file: {WORDLIST_PATH}\n"
|
||||||
|
"Download https://github.com/dwyl/english-words/blob/master/words_alpha.txt "
|
||||||
|
"and save it as words.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(WORDLIST_PATH, "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
w = line.strip()
|
||||||
|
if not w:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# dwyl list is already lowercase and alphabetical —
|
||||||
|
# but we'll enforce that anyway:
|
||||||
|
if not w.isalpha():
|
||||||
|
continue
|
||||||
|
|
||||||
|
words[len(w)].append(w)
|
||||||
|
|
||||||
|
# Load at import time
|
||||||
|
load_wordlist()
|
||||||
Reference in New Issue
Block a user