This commit is contained in:
+1
-3
@@ -13,6 +13,4 @@ conn = psycopg2.connect(
|
|||||||
user= env("PG_USER"),
|
user= env("PG_USER"),
|
||||||
password = env("PG_PASSWORD")
|
password = env("PG_PASSWORD")
|
||||||
)
|
)
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
# Imports
|
# Imports
|
||||||
from flask import Blueprint, render_template, abort, request
|
from flask import Blueprint, render_template, abort
|
||||||
import os, markdown
|
import os, importlib, logging
|
||||||
|
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log = logging.getLogger("__main__")
|
||||||
|
|
||||||
|
|
||||||
# Create blueprint
|
# Create blueprint
|
||||||
bp = Blueprint('dynamic_routes', __name__)
|
bp = Blueprint('dynamic', __name__)
|
||||||
template_folder = "templates"
|
template_folder = "templates"
|
||||||
|
|
||||||
|
|
||||||
@@ -25,6 +29,23 @@ def ListFiles(path):
|
|||||||
return files
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
# Import handlers
|
||||||
|
handlers_unloaded = os.listdir("src/handlers")
|
||||||
|
handlers_unloaded = [handler for handler in handlers_unloaded if handler.endswith(".py")]
|
||||||
|
handlers = {}
|
||||||
|
|
||||||
|
for handler in handlers_unloaded:
|
||||||
|
try:
|
||||||
|
try: module = importlib.import_module(f'src.handlers.{handler[:-3]}')
|
||||||
|
except ImportError: module = importlib.import_module(f'handlers.{handler[:-3]}')
|
||||||
|
handlers[handler[:-3]] = module.callback
|
||||||
|
log.info(f"Loaded handler {handler}: {module.callback}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Failed to load handler {handler}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
# Catch-all route for generic pages
|
# Catch-all route for generic pages
|
||||||
@bp.route('/<path:filename>')
|
@bp.route('/<path:filename>')
|
||||||
def catch_all(filename):
|
def catch_all(filename):
|
||||||
@@ -36,18 +57,17 @@ def catch_all(filename):
|
|||||||
pages=ListFiles(filename)
|
pages=ListFiles(filename)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if filename.split('.')[-1] in handlers.keys():
|
||||||
|
return handlers[filename.split('.')[-1]](filename)
|
||||||
|
|
||||||
return render_template(f'pages/{filename}')
|
return render_template(f'pages/{filename}')
|
||||||
|
|
||||||
elif os.path.exists(get_path(filename + '.html')):
|
elif os.path.exists(get_path(filename + '.html')):
|
||||||
return render_template(f'pages/{filename}.html')
|
return render_template(f'pages/{filename}.html')
|
||||||
|
|
||||||
elif os.path.exists(get_path(filename + '.md')):
|
|
||||||
output = markdown.markdown(open(get_path(filename + '.md'), "r").read())
|
|
||||||
return render_template(
|
|
||||||
f'bases/md.html',
|
|
||||||
title = filename.split("/")[-1],
|
|
||||||
markdown = output
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
for ext in handlers.keys():
|
||||||
|
if os.path.exists(get_path(filename + f".{ext}")):
|
||||||
|
return handlers[ext](filename + f".{ext}")
|
||||||
|
|
||||||
abort(404, f"'{filename}' not found")
|
abort(404, f"'{filename}' not found")
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from flask import render_template
|
||||||
|
import markdown, os
|
||||||
|
|
||||||
|
def callback(filename):
|
||||||
|
output = markdown.markdown(open(os.path.join("templates", "pages", filename), "r").read())
|
||||||
|
return render_template(
|
||||||
|
f'bases/md.html',
|
||||||
|
title = filename.split("/")[-1],
|
||||||
|
markdown = output
|
||||||
|
)
|
||||||
+11
-5
@@ -4,11 +4,9 @@ from os import getenv as env
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import src.dynamic_routes as dynamic_routes
|
|
||||||
import src.errors as errors
|
|
||||||
import src.pg_log as pg_log
|
import src.pg_log as pg_log
|
||||||
except ImportError:
|
except ImportError:
|
||||||
import dynamic_routes, errors, pg_log
|
import pg_log
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
@@ -42,6 +40,14 @@ werkzeug_logger.addHandler(stream_log_handler)
|
|||||||
log.info("Logging initialized.")
|
log.info("Logging initialized.")
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
import src.dynamic as dynamic
|
||||||
|
import src.errors as errors
|
||||||
|
import src.music_metadata as music_metadata
|
||||||
|
except ImportError:
|
||||||
|
import dynamic, errors, music_metadata
|
||||||
|
|
||||||
|
|
||||||
# CREATE FLASK APP
|
# CREATE FLASK APP
|
||||||
app = Flask(
|
app = Flask(
|
||||||
__name__,
|
__name__,
|
||||||
@@ -53,7 +59,8 @@ log.info("Flask initialized.")
|
|||||||
|
|
||||||
# BLUEPRINTS
|
# BLUEPRINTS
|
||||||
app.register_blueprint(errors.bp, url_prefix="/errors")
|
app.register_blueprint(errors.bp, url_prefix="/errors")
|
||||||
app.register_blueprint(dynamic_routes.bp, url_prefix="/")
|
app.register_blueprint(music_metadata.bp, url_prefix="/music")
|
||||||
|
app.register_blueprint(dynamic.bp, url_prefix="/")
|
||||||
log.info("Blueprints registered.")
|
log.info("Blueprints registered.")
|
||||||
|
|
||||||
|
|
||||||
@@ -71,7 +78,6 @@ def terminal():
|
|||||||
return render_template("terminal.html")
|
return render_template("terminal.html")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# DEBUG (DONT RUN LIKE THIS IN PROD)
|
# DEBUG (DONT RUN LIKE THIS IN PROD)
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
log.warning(f"RUNNING IN DEBUG MODE DO NOT USE FOR PRODUCTION!")
|
log.warning(f"RUNNING IN DEBUG MODE DO NOT USE FOR PRODUCTION!")
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from flask import Blueprint, request, abort
|
||||||
|
from os import getenv as env
|
||||||
|
from urllib.parse import quote
|
||||||
|
import requests
|
||||||
|
|
||||||
|
bp = Blueprint("music", __name__)
|
||||||
|
|
||||||
|
@bp.route("/metadata")
|
||||||
|
def metadata():
|
||||||
|
if not request.args.get("recording_name"):
|
||||||
|
abort(400, "Recording name is required")
|
||||||
|
if not request.args.get("artist_name"):
|
||||||
|
abort(400, "Artist name is required")
|
||||||
|
|
||||||
|
data = requests.get(
|
||||||
|
f"https://api.listenbrainz.org/1/metadata/lookup/?recording_name={quote(request.args.get("recording_name"))}&artist_name={quote(request.args.get("artist_name"))}&metadata=true&inc=artist+tag+release",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Token {env("LISTENBRAINZ_TOKEN")}"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.status_code == 200:
|
||||||
|
return data.json()
|
||||||
|
else:
|
||||||
|
abort(500, "Failed to retrieve metadata")
|
||||||
@@ -40,6 +40,8 @@
|
|||||||
--smileos2-box: url(/static/content/smileos/SmileOS_2_Box.webp) 17 3 3 fill / 51px 9px 9px;
|
--smileos2-box: url(/static/content/smileos/SmileOS_2_Box.webp) 17 3 3 fill / 51px 9px 9px;
|
||||||
--smileos2-font: 'Ultrafont2';
|
--smileos2-font: 'Ultrafont2';
|
||||||
--smileos2-emphasis: #FF4343;
|
--smileos2-emphasis: #FF4343;
|
||||||
|
--line-height: normal;
|
||||||
|
--header-line-height: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -48,6 +50,7 @@ body {
|
|||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
font-size: var(--font-size);
|
font-size: var(--font-size);
|
||||||
font-family: var(--font-family);
|
font-family: var(--font-family);
|
||||||
|
line-height: var(--line-height);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto auto;
|
grid-template-columns: auto auto;
|
||||||
grid-template-rows: 1fr;
|
grid-template-rows: 1fr;
|
||||||
@@ -58,6 +61,10 @@ body {
|
|||||||
width: 940px;
|
width: 940px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
line-height: var(--header-line-height);
|
||||||
|
}
|
||||||
|
|
||||||
#sidebar {
|
#sidebar {
|
||||||
grid-area: sidebar;
|
grid-area: sidebar;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+19
-5
@@ -43,7 +43,7 @@
|
|||||||
transform: scale(1.1) rotate(-5deg);
|
transform: scale(1.1) rotate(-5deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
#spotify {
|
#listenbrainz {
|
||||||
background-image: none;
|
background-image: none;
|
||||||
backdrop-filter: blur(2px) brightness(0.6);
|
backdrop-filter: blur(2px) brightness(0.6);
|
||||||
border: var(--secondary-background-color) 2px solid;
|
border: var(--secondary-background-color) 2px solid;
|
||||||
@@ -58,8 +58,8 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
#spotify-title {
|
#listenbrainz-title {
|
||||||
font-size: 1.5rem;
|
font-size: 1.2rem;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -67,15 +67,29 @@
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
#spotify-artist {
|
#listenbrainz-artist {
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 1rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
mix-blend-mode: difference;
|
mix-blend-mode: difference;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#listenbrainz-live {
|
||||||
|
color: white;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 900;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: auto;
|
||||||
|
margin-left: auto;
|
||||||
|
background-color: gray;
|
||||||
|
border-radius: 30px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-family: var(--ultrafont-font);
|
||||||
|
}
|
||||||
|
|
||||||
#button-collection {
|
#button-collection {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|||||||
+13
-53
@@ -76,72 +76,32 @@ let last15Chars = "";
|
|||||||
|
|
||||||
document.addEventListener('keydown', function(event) {
|
document.addEventListener('keydown', function(event) {
|
||||||
last15Chars += event.key;
|
last15Chars += event.key;
|
||||||
if (last15Chars.includes("furry")) {
|
if (last15Chars.includes("owo")) {
|
||||||
console.log("owo, whats this?");
|
console.log("owo, whats this?");
|
||||||
document.getElementById('furry').style.display = 'block';
|
document.getElementById('furry').style.display = 'block';
|
||||||
last15Chars = "";
|
last15Chars = "";
|
||||||
}
|
}
|
||||||
if (last15Chars.includes("irken")) {
|
if (last15Chars.includes("doom")) {
|
||||||
console.log("doom doom doom!");
|
console.log("Im gonna sing the doom song now");
|
||||||
document.querySelector(":root").style.setProperty('--font-family', 'Irken');
|
document.querySelector(":root").style.setProperty('--font-family', 'Irken');
|
||||||
|
document.querySelector(":root").style.setProperty('--ultrafont-font', 'Irken');
|
||||||
|
document.querySelector(":root").style.setProperty('--smileos2-font', 'Irken');
|
||||||
document.querySelector(":root").style.setProperty('--title-font', '1.5em');
|
document.querySelector(":root").style.setProperty('--title-font', '1.5em');
|
||||||
|
document.querySelector(":root").style.setProperty('--line-height', 'default');
|
||||||
|
document.querySelector(":root").style.setProperty('--header-line-height', 'default');
|
||||||
last15Chars = "";
|
last15Chars = "";
|
||||||
}
|
}
|
||||||
if (last15Chars.includes("scratch")) {
|
if (last15Chars.includes("kfc")) {
|
||||||
console.log("space chicken");
|
console.log("space chicken");
|
||||||
document.querySelector(":root").style.setProperty('--font-family', 'Scratch');
|
document.querySelector(":root").style.setProperty('--font-family', 'Scratch');
|
||||||
document.querySelector(":root").style.setProperty('--title-font', '1em');
|
document.querySelector(":root").style.setProperty('--title-font', '1em');
|
||||||
|
document.querySelector(":root").style.setProperty('--line-height', '120%');
|
||||||
|
document.querySelector(":root").style.setProperty('--header-line-height', '150%');
|
||||||
|
document.querySelector(":root").style.setProperty('--ultrafont-font', 'Scratch');
|
||||||
|
document.querySelector(":root").style.setProperty('--smileos2-font', 'Scratch');
|
||||||
last15Chars = "";
|
last15Chars = "";
|
||||||
}
|
}
|
||||||
while (last15Chars.length >= 15) {
|
while (last15Chars.length >= 15) {
|
||||||
last15Chars = last15Chars.slice(1);
|
last15Chars = last15Chars.slice(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Spotify API (now lastfm)
|
|
||||||
|
|
||||||
function getSpotify() {
|
|
||||||
fetch('https://api.alfieking.dev/spotify/nowplaying/xz02oolstlvwxqu1pfcua9exz').then(response => {
|
|
||||||
return response.json();
|
|
||||||
}).then(data => {
|
|
||||||
if (data.item == null) {
|
|
||||||
document.getElementById('spotify').style.backgroundImage = "none";
|
|
||||||
document.getElementById('spotify-title').innerHTML = "Spotify is not playing anything";
|
|
||||||
document.getElementById('spotify-artist').innerHTML = ":(";
|
|
||||||
document.getElementById('spotify-link').href = "https://open.spotify.com/";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
document.getElementById('spotify').style.backgroundImage = "url(" + data.item.album.images[0].url + ")";
|
|
||||||
document.getElementById('spotify-title').innerHTML = data.item.name;
|
|
||||||
document.getElementById('spotify-artist').innerHTML = data.item.artists[0].name;
|
|
||||||
document.getElementById('spotify-link').href = data.item.external_urls.spotify;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.getElementById('spotify')) {
|
|
||||||
getSpotify();
|
|
||||||
setInterval(getSpotify, 15000);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// load buttons
|
|
||||||
|
|
||||||
function loadButtons() {
|
|
||||||
fetch('/static/content/buttons/non_link_buttons.txt').then(response => {
|
|
||||||
return response.text();
|
|
||||||
}).then(data => {
|
|
||||||
container = document.getElementById('button-collection');
|
|
||||||
for (let line of data.split('\n')) {
|
|
||||||
if (line == "") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let img = document.createElement('img');
|
|
||||||
img.src = line;
|
|
||||||
container.appendChild(img);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.getElementById('button-collection')) {
|
|
||||||
loadButtons();
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
function loadButtons() {
|
||||||
|
fetch('/static/content/buttons/non_link_buttons.txt').then(response => {
|
||||||
|
return response.text();
|
||||||
|
}).then(data => {
|
||||||
|
container = document.getElementById('button-collection');
|
||||||
|
for (let line of data.split('\n')) {
|
||||||
|
if (line == "") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let img = document.createElement('img');
|
||||||
|
img.src = line;
|
||||||
|
container.appendChild(img);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function getAlbumArt(songName, artistName) {
|
||||||
|
fetch("/music/metadata?recording_name=" + encodeURIComponent(songName) + "&artist_name=" + encodeURIComponent(artistName)).then(response => response.json()).then(data => {
|
||||||
|
if (Object.keys(data).length > 0) {
|
||||||
|
document.getElementById('listenbrainz').style.backgroundImage = "url(https://coverartarchive.org/release/" + data.release_mbid + "/front)";
|
||||||
|
} else {
|
||||||
|
console.log("No album art found for the given song and artist");
|
||||||
|
document.getElementById('listenbrainz').style.backgroundImage = "url(https://placehold.co/512x512/transparent/777?text=[Insert%20art%20here])";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMusic() {
|
||||||
|
fetch("https://api.listenbrainz.org/1/user/acetheking987/playing-now").then(response => response.json()).then(data => {
|
||||||
|
if (data.payload.count != 0) {
|
||||||
|
let song_data = data.payload.listens[0].track_metadata;
|
||||||
|
getAlbumArt(song_data.track_name, song_data.artist_name);
|
||||||
|
document.getElementById('listenbrainz-title').innerHTML = song_data.track_name;
|
||||||
|
document.getElementById('listenbrainz-artist').innerHTML = song_data.artist_name;
|
||||||
|
document.getElementById('listenbrainz-link').href = "https://listenbrainz.org/user/acetheking987/";
|
||||||
|
document.getElementById('listenbrainz-live').style.backgroundColor = "red";
|
||||||
|
document.getElementById('listenbrainz-live').innerHTML = "Live";
|
||||||
|
} else {
|
||||||
|
fetch("https://api.listenbrainz.org/1/user/acetheking987/listens?count=1").then(response => response.json()).then(data => {
|
||||||
|
if (data.payload.count != 0) {
|
||||||
|
let song_data = data.payload.listens[0].track_metadata;
|
||||||
|
getAlbumArt(song_data.track_name, song_data.artist_name);
|
||||||
|
document.getElementById('listenbrainz-title').innerHTML = song_data.track_name;
|
||||||
|
document.getElementById('listenbrainz-artist').innerHTML = song_data.artist_name;
|
||||||
|
document.getElementById('listenbrainz-link').href = "https://listenbrainz.org/user/acetheking987/";
|
||||||
|
document.getElementById('listenbrainz-live').style.backgroundColor = "grey";
|
||||||
|
document.getElementById('listenbrainz-live').innerHTML = "Not Live :<";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadButtons();
|
||||||
|
getMusic();
|
||||||
|
setInterval(getMusic, 15000);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Gallery - Toasters's basement{% endblock %}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -38,10 +38,11 @@
|
|||||||
<img src="https://s1nez.nekoweb.org/img/7dcd20d4.gif" alt="">
|
<img src="https://s1nez.nekoweb.org/img/7dcd20d4.gif" alt="">
|
||||||
</section>
|
</section>
|
||||||
<div class="flex-row">
|
<div class="flex-row">
|
||||||
<a href="https://www.last.fm/user/acetheking987" id="spotify-link">
|
<a href="https://listenbrainz.org/user/acetheking987/" id="listenbrainz-link">
|
||||||
<div id="spotify">
|
<div id="listenbrainz">
|
||||||
<h1 id="spotify-title"></h1>
|
<h1 id="listenbrainz-title">Loading...</h1>
|
||||||
<h2 id="spotify-artist"></h2>
|
<h2 id="listenbrainz-artist">be patient, listenbrainz is slow :P</h2>
|
||||||
|
<h3 id="listenbrainz-live">Not Live :<</h3>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<section class="stamps">
|
<section class="stamps">
|
||||||
@@ -159,4 +160,8 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
<iframe src="https://john.citrons.xyz/embed?ref=alfieking.dev" style="margin-left:auto;display:block;margin-right:auto;max-width:732px;width:100%;height:94px;border:none;"></iframe>
|
<iframe src="https://john.citrons.xyz/embed?ref=alfieking.dev" style="margin-left:auto;display:block;margin-right:auto;max-width:732px;width:100%;height:94px;border:none;"></iframe>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="/static/js/index.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{% extends "bases/base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section>
|
||||||
|
<h1>Test</h1>
|
||||||
|
<img src="static/content/photos/fur_meets/26-07-2025_critters_mk/PXL_20250726_155226274.jpg" alt="toaster" id="img1">
|
||||||
|
<p id="makeAndModel"></p>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/exif-js"></script>
|
||||||
|
<script>
|
||||||
|
window.onload=getExif;
|
||||||
|
|
||||||
|
function getExif() {
|
||||||
|
var img1 = document.getElementById("img1");
|
||||||
|
EXIF.getData(img1, function() {
|
||||||
|
var make = EXIF.pretty(this);
|
||||||
|
makeAndModel.innerHTML = `${make}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user