make css better and add add calendar button

This commit is contained in:
Ugric committed 2026-09-17 23:42:33 +01:00
1 parent 9bb598dada
commit 3bfe06a8ad
8 files changed
+694 -220

No files matched your search

+368 -53
View File
@@ -3,7 +3,16 @@ from datetime import datetime, timezone
import pytz
import hashlib
import os
from flask import Flask, request, send_file, render_template, Response, redirect, url_for
from flask import (
Flask,
request,
send_file,
render_template,
Response,
redirect,
url_for,
session,
)
import requests
from requests_ntlm import HttpNtlmAuth
import urllib
@@ -12,112 +21,418 @@ from cov2ics import *
import base64
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from dotenv import load_dotenv
import codecs
import urllib.parse
import json
from flask_wtf.csrf import CSRFProtect
from flask_wtf.csrf import CSRFProtect
load_dotenv()
def get_auth():
username = None
auth_encrypted_encoded = request.cookies.get("auth")
nonce_encoded = request.cookies.get("nonce")
if auth_encrypted_encoded and nonce_encoded:
auth_encrypted = base64.b64decode(auth_encrypted_encoded)
nonce = base64.b64decode(nonce_encoded)
auth = json.loads(aes.decrypt(nonce, auth_encrypted, None).decode("utf-8"))
return fetch_auth_status(f"COVENTRY\\{auth['u']}", auth['p']), auth['u'], auth["p"]
return False, None, None
def generate_auth(username, password):
nonce = os.urandom(12)
auth_encrypted = aes.encrypt(nonce, json.dumps({"u": username, "p": password}).encode(), None)
return auth_encrypted, nonce
# print(URL.format(urllib.parse.quote_plus("hello/world")))
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
app = Flask(__name__)
# Generate a 256-bit (32-byte) key
# key = AESGCM.generate_key(bit_length=256)
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY")
key = codecs.decode(os.getenv("AES_KEY"), 'hex')
app.config.update(
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
)
# Create the AES-GCM cipher
aes = AESGCM(key)
csrf = CSRFProtect(app)
# AES_KEY should be a 64-character hexadecimal string representing
# exactly 32 random bytes (256 bits).
#
# Generate one with:
#
# python -c "import secrets; print(secrets.token_hex(32))"
#
# Store the result in your environment as AES_KEY.
aes_key = bytes.fromhex(os.environ["AES_KEY"])
if len(aes_key) != 32:
raise RuntimeError("AES_KEY must be exactly 32 bytes / 64 hex characters")
aes = AESGCM(aes_key)
URL = os.getenv("URL")
WEBCAL_URL = os.getenv("WEBCAL_URL")
# ---------------------------------------------------------------------------
# Stateless authentication token
# ---------------------------------------------------------------------------
AUTH_TOKEN_VERSION = b"\x01"
AUTH_TOKEN_AAD = b"timtabla-auth-v1"
# 12 bytes is the recommended nonce size for AES-GCM.
NONCE_SIZE = 12
# Ten years for the browser login cookie.
AUTH_MAX_AGE = 10 * 365 * 24 * 60 * 60
def b64encode(data: bytes) -> str:
"""Base64URL encode without '=' padding."""
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def b64decode(data: str) -> bytes:
"""Base64URL decode with optional '=' padding."""
padding = "=" * (-len(data) % 4)
return base64.urlsafe_b64decode(data + padding)
def generate_auth_token(username: str, password: str) -> str:
"""
Create a stateless encrypted authentication token.
Token layout:
version (1 byte)
nonce (12 bytes)
ciphertext + GCM authentication tag
The plaintext contains the university credentials.
"""
payload = {
"u": username,
"p": password,
"exp": int(datetime.now(timezone.utc).timestamp()) + AUTH_MAX_AGE,
}
plaintext = json.dumps(
payload,
separators=(",", ":"),
).encode("utf-8")
nonce = os.urandom(NONCE_SIZE)
ciphertext = aes.encrypt(
nonce,
plaintext,
AUTH_TOKEN_AAD,
)
token = AUTH_TOKEN_VERSION + nonce + ciphertext
return b64encode(token)
def decrypt_auth_token(token: str):
"""
Decrypt and validate an authentication token.
Returns:
(username, password)
Returns:
None
if the token is invalid, expired, or has been tampered with.
"""
try:
raw = b64decode(token)
# Minimum:
# 1 byte version
# 12 byte nonce
# 16 byte GCM authentication tag
if len(raw) < 1 + NONCE_SIZE + 16:
return None
version = raw[0:1]
if version != AUTH_TOKEN_VERSION:
return None
nonce = raw[1 : 1 + NONCE_SIZE]
ciphertext = raw[1 + NONCE_SIZE :]
plaintext = aes.decrypt(
nonce,
ciphertext,
AUTH_TOKEN_AAD,
)
payload = json.loads(plaintext.decode("utf-8"))
username = payload["u"]
password = payload["p"]
expiry = int(payload["exp"])
if not isinstance(username, str) or not isinstance(password, str):
return None
if datetime.now(timezone.utc).timestamp() >= expiry:
return None
return username, password
except Exception:
# This deliberately catches malformed Base64, invalid JSON,
# invalid GCM tags, missing fields, etc.
return None
def get_browser_auth():
"""
Get credentials from the browser's encrypted auth cookie.
Returns:
(status, username, password)
"""
token = request.cookies.get("auth")
if not token:
return False, None, None
credentials = decrypt_auth_token(token)
if credentials is None:
return False, None, None
username, password = credentials
try:
status = fetch_auth_status(
f"COVENTRY\\{username}",
password,
)
except Exception:
status = False
return status, username, password
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.route("/")
def index():
status, username, password = get_auth()
status, username, password = get_browser_auth()
if status:
return redirect("/dashboard")
return render_template("home.jinja")
@app.route("/about")
def about():
return render_template("about.jinja")
@app.route("/login", methods=["GET"])
def login():
status, username, password = get_auth()
session.clear()
status, username, password = get_browser_auth()
if status:
return redirect("/dashboard")
return render_template("login.jinja")
@app.route("/login", methods=["POST"])
def login_post():
username = request.form.get("username")
password = request.form.get("password")
auth, nonce = generate_auth(username, password)
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
if not username or not password:
return (
render_template(
"login.jinja",
error="Username and password are required.",
),
400,
)
# Verify the credentials before storing them in the encrypted cookie.
try:
status = fetch_auth_status(
f"COVENTRY\\{username}",
password,
)
except Exception:
status = False
if not status:
return (
render_template(
"login.jinja",
error="Invalid university username or password.",
),
401,
)
token = generate_auth_token(username, password)
resp = redirect("/dashboard")
resp.set_cookie("auth", base64.b64encode(auth).decode("utf-8"), max_age=10 * 365 * 24 * 60 * 60)
resp.set_cookie("nonce", base64.b64encode(nonce).decode("utf-8"), max_age=10 * 365 * 24 * 60 * 60)
resp.set_cookie(
"auth",
token,
max_age=AUTH_MAX_AGE,
secure=True,
httponly=True,
samesite="Lax",
)
return resp
@app.route("/logout", methods=["POST"])
def logout():
session.clear()
resp = redirect("/")
resp.delete_cookie(
"auth",
secure=True,
httponly=True,
samesite="Lax",
)
return resp
@app.route("/dashboard")
def dashboard():
status, username, password = get_auth()
status, username, password = get_browser_auth()
if not status:
return redirect("/login")
auth, nonce = generate_auth(username, password)
auth_encoded = base64.b64encode(auth).decode("utf-8")
nonce_encoded = base64.b64encode(nonce).decode("utf-8")
url = "/timtabla.ics?"+urllib.parse.urlencode({"a": auth_encoded, "n": nonce_encoded})
return render_template("dashboard.jinja", username=username, url=url)
# Generate a completely new token for the feed URL.
#
# This means the browser cookie and the calendar feed do not
# contain identical ciphertext.
token = generate_auth_token(username, password)
webcal_url = WEBCAL_URL+"/timtabla.ics?" + urllib.parse.urlencode({"t": token})
return render_template(
"dashboard.jinja",
username=username,
webcal_url=webcal_url,
)
@app.route("/download-ics", methods=["GET"])
def download_ics():
status, username, password = get_auth()
status, username, password = get_browser_auth()
if not status:
return redirect("/login")
try:
data, start_year, start_month, end_year, end_month = build_calendar(fetch_timetable(f"COVENTRY\\{username}", password))
data, start_year, start_month, end_year, end_month = build_calendar(
fetch_timetable(
f"COVENTRY\\{username}",
password,
)
)
return Response(
data,
mimetype="text/calendar",
headers={"Content-Disposition": f'attachment; filename="{username}_timtabla_{start_month}-{start_year}_to_{end_month}-{end_year}.ics"'},
headers={
"Content-Disposition": (
f"attachment; "
f'filename="{username}_timtabla_'
f"{start_month}-{start_year}_to_"
f'{end_month}-{end_year}.ics"'
)
},
)
except Exception as e:
except Exception:
return "Calendar Not Found", 404
@app.route("/timtabla.ics", methods=["GET"])
def live_ics():
auth_encrypted = base64.b64decode(request.args["a"])
nonce = base64.b64decode(request.args["n"])
auth = json.loads(aes.decrypt(nonce, auth_encrypted, None).decode("utf-8"))
"""
Live calendar endpoint.
Example:
/timtabla.ics?t=BASE64URL_TOKEN
The token is a bearer credential. Anyone possessing the URL
can access the timetable until the token expires or the
university password stops working.
"""
token = request.args.get("t")
if not token:
return "Invalid calendar URL", 401
credentials = decrypt_auth_token(token)
if credentials is None:
return "Invalid or expired calendar URL", 401
username, password = credentials
try:
data, start_year, start_month, end_year, end_month = build_calendar(fetch_timetable(f"COVENTRY\\{auth["u"]}", auth["p"]))
return Response(
# Optionally verify that the university credentials still work.
status = fetch_auth_status(
f"COVENTRY\\{username}",
password,
)
if not status:
return "University authentication failed", 401
data, start_year, start_month, end_year, end_month = build_calendar(
fetch_timetable(
f"COVENTRY\\{username}",
password,
)
)
response = Response(
data,
mimetype="text/calendar",
headers={"Content-Disposition": f'attachment; filename="{auth["u"]}_timtabla.ics"'},
headers={
"Content-Disposition": (
f'attachment; filename="{username}_timtabla.ics"'
),
# Don't allow intermediary caches to retain the
# generated timetable.
"Cache-Control": "no-store",
# Prevent the private feed URL from being sent as
# a Referer header to another site.
"Referrer-Policy": "no-referrer",
},
)
except Exception as e:
return response
except Exception:
return "Calendar Not Found", 404
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port=3000)
app.run(
debug=True,
host="0.0.0.0",
port=3000,
)
+285 -133
View File
@@ -1,226 +1,367 @@
:root {
--text: #e3f2fc;
--background: #031621;
--primary: #a1c5da;
--secondary: #6a139b;
--ink: #031621;
--surface: #0a2434;
--surface-border: #1c3d52;
--paper: #e3f2fc;
--paper-dim: #7fa5bd;
--accent: #52ace0;
--accent-dim: #3d8bb8;
--grid-line: rgba(227, 242, 252, 0.04);
--font-sans: -apple-system, "Segoe UI", Inter, Helvetica, Arial, sans-serif;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
}
* {
box-sizing: border-box;
}
html {
color-scheme: dark;
}
body {
font-family: Arial, sans-serif;
color: var(--text);
font-family: var(--font-sans);
color: var(--paper);
margin: 0;
background: var(--background);
background:
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px),
var(--ink);
background-size: 48px 48px, 48px 48px, auto;
background-attachment: fixed;
display: flex;
flex-direction: column;
min-height: 100dvh;
-webkit-font-smoothing: antialiased;
}
body::before {
content: "";
position: fixed;
top: 0;
left: 0;
width: 200vw;
height: 200vh;
background: radial-gradient(circle at 50% 0%, var(--accent) 0%, var(--background) 70%);
animation: moveLight 10s ease-in-out infinite alternate;
z-index: -1;
@media (prefers-reduced-motion: no-preference) {
.header-mark {
animation: mark-in 0.5s cubic-bezier(0.16, 1, 0.3, 1) both;
}
}
@keyframes moveLight {
0% {
transform: translate(-50%, -50%);
@keyframes mark-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
50% {
transform: translate(-25%, -40%);
}
100% {
transform: translate(0%, -50%);
}
a {
color: inherit;
}
.page-content {
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1rem;
padding: 2rem 1rem;
gap: 2.5rem;
}
/* Site mark */
.title {
display: flex;
justify-content: center;
font-size: 80px;
font-weight: bold;
text-shadow:
0 20px 40px rgba(0, 0, 0, 0.2),
0 80px 200px rgba(255, 255, 255, 0.15);
align-items: baseline;
gap: 0.6rem;
text-decoration: none;
color: whitesmoke;
color: var(--paper);
}
.title p {
margin: 0;
font-size: 3rem;
font-weight: 700;
letter-spacing: -0.02em;
text-shadow:
0 20px 40px rgba(0, 0, 0, 0.2),
0 80px 200px rgba(255, 255, 255, 0.1);
}
.login-box {
width: fit-content;
height: fit-content;
max-height: 100dvh;
background: var(--background);
padding: 30px;
border-radius: 8px;
text-align: center;
border: solid 1px var(--accent);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
/* Shared panel treatment for the login + text-content boxes */
.login-box,
.text-box {
width: 100%;
max-width: 30rem;
background: var(--surface);
border: 1px solid var(--surface-border);
border-radius: 12px;
padding: 2rem;
display: flex;
flex-direction: column;
align-items: center;
align-items: stretch;
gap: 0.9rem;
}
.text-box {
font-size: 20px;
max-width: 90%;
max-width: 800px;
height: fit-content;
max-height: 100dvh;
background: var(--background);
padding: 30px;
border-radius: 8px;
text-align: center;
border: solid 1px var(--accent);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
align-items: center;
max-width: 40rem;
font-size: 1.0625rem;
line-height: 1.65;
color: var(--paper);
}
.text-box p {
margin: 0 0 0.6rem;
}
.text-box h1 {
font-size: 1.3rem;
font-weight: 700;
margin: 0.6rem 0 0.2rem;
color: var(--paper);
}
.text-box code {
font-family: var(--font-mono);
background: var(--ink);
border: 1px solid var(--surface-border);
border-radius: 4px;
padding: 0.1rem 0.35rem;
}
.text-box b {
color: var(--accent);
font-weight: 600;
}
.text-box a:not(.button) {
color: var(--accent);
text-decoration: underline;
text-decoration-color: var(--accent-dim);
text-underline-offset: 2px;
}
.text-box a:hover:not(.button) {
color: var(--paper);
}
/* Login form */
.login-box {
text-align: left;
}
.login-box h2 {
margin: 0 0 0.4rem;
font-size: 1.25rem;
font-weight: 700;
}
.field-label {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--paper-dim);
letter-spacing: 0.02em;
margin: 0.3rem 0 -0.35rem;
}
.login-box input {
width: 100%;
padding: 8px;
margin: 8px 0;
background-color: var(--background);
color: var(--text);
border: solid 1px var(--accent);
padding: 0.65rem 0.7rem;
background: var(--ink);
color: var(--paper);
border: 1px solid var(--surface-border);
border-radius: 8px;
font-size: 0.95rem;
font-family: var(--font-sans);
transition: border-color 0.15s ease;
}
.login-box input::placeholder {
color: var(--paper-dim);
}
.login-box input:focus,
.login-box input:focus-visible {
outline: none;
border-color: var(--accent);
}
.login-box .hint {
font-size: 0.8rem;
color: var(--paper-dim);
margin: -0.3rem 0 0.2rem;
}
/* Buttons */
button,
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.7rem 1.1rem;
background: var(--accent);
color: var(--ink);
font-size: 0.95rem;
font-weight: 700;
border: none;
border-radius: 8px;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
font-family: var(--font-sans);
transition: background-color 0.15s ease, transform 0.1s ease;
}
button {
margin-top: 1rem;
width: 100%;
padding: 10px;
background: var(--accent);
color: var(--background);
font-size: large;
font-weight: bold;
border: none;
cursor: pointer;
margin-top: 0.5rem;
}
.button {
margin-top: 1rem;
width: fit-content;
min-width: 7.5rem;
padding: 10px;
background: var(--accent);
color: var(--background);
font-size: large;
font-weight: bold;
border: none;
cursor: pointer;
text-decoration: none;
}
.div-image {
background-size: cover;
background-position: center;
max-width: 80%;
max-height: 80%;
aspect-ratio: 1/1;
object-fit: scale-down;
display: block;
margin: 0 auto;
border-radius: 10px;
button:hover,
.button:hover {
background: var(--accent-dim);
}
button:active,
.button:active {
transform: translateY(1px);
}
button:hover {
background: var(--primary);
color: var(--background);
button:focus-visible,
.button:focus-visible,
a:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.button.secondary {
background: transparent;
color: var(--paper);
border: 1px solid var(--surface-border);
}
.button.secondary:hover {
border-color: var(--accent);
background: var(--surface);
}
.logout-form {
width: fit-content;
}
.logout-form button {
background: transparent;
color: var(--paper-dim);
border: 1px solid var(--surface-border);
width: auto;
padding: 0.6rem 1rem;
font-weight: 600;
margin-top: 0;
}
.logout-form button:hover {
color: var(--paper);
border-color: var(--accent-dim);
background: var(--surface);
}
.error {
color: red;
margin-top: 10px;
font-family: var(--font-mono);
font-size: 0.82rem;
color: #ff8a7a;
background: rgba(255, 138, 122, 0.08);
border: 1px solid rgba(255, 138, 122, 0.3);
border-radius: 6px;
padding: 0.5rem 0.6rem;
margin-top: 0.4rem;
}
footer {
--footer-bg: #0d1b2a;
--footer-border: rgba(100, 160, 210, 0.15);
--footer-text: rgba(255, 255, 255, 0.45);
--footer-text-bright: rgba(255, 255, 255, 0.75);
--footer-accent: #5ba4d4;
--footer-badge-bg: rgba(91, 164, 212, 0.1);
--footer-badge-border: rgba(91, 164, 212, 0.25);
background: var(--footer-bg);
border-top: 1px solid var(--footer-border);
padding: 1rem 0.75rem;
font-family: 'Segoe UI', system-ui, sans-serif;
}
footer .title {
/* Dashboard */
.dashboard {
width: 100%;
max-width: 30rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
font-size: 1.1rem;
font-weight: 700;
letter-spacing: 0.03em;
color: var(--footer-text-bright);
gap: 1.25rem;
}
.dashboard .actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
}
.dashboard .actions .button {
flex: 1 1 auto;
}
.dashboard .logout-row {
display: flex;
justify-content: center;
}
.welcome-message {
margin: 1rem;
}
/* Footer */
footer {
--footer-border: rgba(242, 239, 233, 0.08);
border-top: 1px solid var(--footer-border);
padding: 1.5rem 1rem;
font-family: var(--font-sans);
background: var(--ink);
}
footer .width {
max-width: 860px;
max-width: 40rem;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
gap: 0.85rem;
text-align: center;
}
footer .title span {
font-size: 0.95rem;
font-weight: 700;
color: var(--paper);
}
footer .links {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem 0.75rem;
gap: 0.4rem 0.85rem;
}
footer .link {
display: inline-flex;
align-items: center;
gap: 0.35rem;
gap: 0.4rem;
font-size: 0.82rem;
color: var(--footer-accent);
color: var(--paper-dim);
text-decoration: none;
transition: opacity 0.15s ease;
transition: color 0.15s ease;
}
footer .link img {
min-width: 16px;
max-width: 45px;
max-height: 28px;
max-width: 18px;
max-height: 18px;
border-radius: 3px;
}
footer .link:hover {
opacity: 0.75;
text-decoration: underline;
color: whitesmoke;
color: var(--accent);
}
footer .divider {
@@ -231,8 +372,19 @@ footer .divider {
}
footer .disclaimer {
font-size: 0.72rem;
color: var(--footer-text);
font-size: 0.75rem;
color: var(--paper-dim);
margin: 0;
line-height: 1.6;
}
}
@media (max-width: 420px) {
.title p {
font-size: 1.7rem;
}
.login-box,
.text-box {
padding: 1.5rem;
}
}
+5 -12
View File
@@ -1,17 +1,10 @@
{%set page_title = "Home"%}
{%set page_title = "About"%}
{% extends "base.jinja" %}
{% block body %}
<div class="text-box">
<p>
Timtabla is based on code originally created by Oliver Bell and more recently updated by Will Bell (Ugric) and Ché
Shand (TussockyJoker). It is open-source and licensed under the AGPL, the code is available at git.wbell.dev.
</p>
<h1> A brief explanation </h1>
<p>
The way that timtabla (or cov2ics) works is by requesting your data from the Cov Uni server using your username and
password, these values are not stored on our server and this is no less secure than logging in to the timetable site directly.
Obviously this method is not supported by the University and is only possible due to their outdated system.
</p>
<p>Timtabla is based on code originally created by Oliver Bell, and more recently updated by Will Bell (Ugric) and Ché Shand (TussockyJoker). It's open-source and licensed under the AGPL — the code is available at git.wbell.dev.</p>
<h1>A brief explanation</h1>
<p>Timtabla (or cov2ics) works by requesting your data from the Coventry University server using your username and password. These values are not stored on our server, and this is no less secure than logging in to the timetable site directly. This method isn't supported by the university, and is only possible because of their outdated system.</p>
</div>
{% endblock %}
{% endblock %}
+4 -4
View File
@@ -17,13 +17,13 @@
<body>
<div class="page-content">
<a href="{{ url_for('index') }}" class="title">
<p >Timtabla</p>
<a href="{{ url_for('index') }}" class="title header-mark">
<p>Timtabla</p>
</a>
{% block body %}{% endblock %}
</div>
{%include "footer.jinja"%}
</body>
</html>
</html>
+12 -2
View File
@@ -1,7 +1,17 @@
{%set page_title = "Dashboard"%}
{% extends "base.jinja" %}
{% block body %}
<div>
<p>{{url}}</p>
<div class="dashboard">
<p class="welcome-message">Hi, {{username}}!</p>
<div class="actions">
<a href="{{webcal_url}}" class="button">Add to calendar app</a>
<a href="/download-ics" class="button secondary">Download .ics file</a>
</div>
<div class="logout-row">
<form class="logout-form" method="POST" action="{{ url_for('logout') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit">Log out</button>
</form>
</div>
</div>
{% endblock %}
+4 -4
View File
@@ -6,14 +6,14 @@
<div class="links">
<a href="https://git.wbell.dev/Ugric/cov-to-ics" class="link">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gitea.svg">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gitea.svg" alt="">
Source Code
</a>
<span class="divider" aria-hidden="true">·</span>
<a href="https://git.wbell.dev/TussockyJoker/Aula-Fixer/" class="link">
<img src="https://addons.mozilla.org/user-media/addon_icons/2983/2983234-64.png?modified=5425cf62">
<img src="https://addons.mozilla.org/user-media/addon_icons/2983/2983234-64.png?modified=5425cf62" alt="">
Aula Fixer Web Extension
</a>
<span class="divider" aria-hidden="true">·</span>
</div>
<p class="disclaimer">
@@ -22,4 +22,4 @@
</p>
</div>
</footer>
</footer>
+5 -7
View File
@@ -3,11 +3,9 @@
{% block body %}
<div class="text-box">
<p> Timtabla is a third-party website for Coventry University students to convert their timetable to an <code style="font-size: 17px;">.ics</code> file which can be imported into any modern calendar app.</p>
<b> We do not store your university credentials.</b>
<p> This website is based on open-source code developed by Coventry University
students, you can find this code at <a href="https://git.wbell.dev/Ugric/cov-to-ics">git.wbell.dev</a>
</p>
<a href="{{ url_for('login') }}" class="button"> Convert timetable </a>
<p>Timtabla is a third-party website for Coventry University students to convert their timetable to an <code>.ics</code> file, which can be imported into any modern calendar app.</p>
<b>We do not store your university credentials.</b>
<p>This website is based on open-source code developed by Coventry University students. You can find this code at <a href="https://git.wbell.dev/Ugric/cov-to-ics">git.wbell.dev</a>.</p>
<a href="{{ url_for('login') }}" class="button">Convert timetable</a>
</div>
{% endblock %}
{% endblock %}
+11 -5
View File
@@ -2,14 +2,20 @@
{% extends "base.jinja" %}
{% block body %}
<form class="login-box" method="POST" action="{{url_for('login_post')}}">
<h2>Coventry University Login</h2>
<h2>Coventry University login</h2>
<input type="text" name="username" placeholder="Username" required>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="password" name="password" placeholder="Password" required>
<div class="field-label">Username</div>
<input type="text" name="username" placeholder="JokerT53" required>
<button type="submit">Login</button>
<div class="field-label">Password</div>
<input type="password" name="password" placeholder="••••••••" required>
<p class="hint">Your credentials are sent straight to the university server and never stored on our servers.</p>
<button type="submit">Log in</button>
{% if error|trim %}<div class="error">{{error}}</div>{% endif %}
</form>
{% endblock %}
{% endblock %}