forked from Ugric/cov-to-ics
make css better and add add calendar button
This commit is contained in:
1 parent
9bb598dada
commit
3bfe06a8ad
8 files changed
+694
-220
No files matched your search
@@ -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
@@ -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
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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
@@ -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 %}
|
||||
Reference in new issue
Block a user