Posting Forms#
๐ฅ Hot Tips
- Append
~RANDOM()to filters for dynamic tokens - Redirect after successful POST (Post/Redirect/Get pattern)
- Use
request.bodyfor form data,request.paramsfor query @noauth()only for trusted public endpoints- Tokens auto-refresh via
FreshTokenheader
If you're used to posting forms in the traditional manner to the web service, pay attention to the following:
- All
POST,PUT,PATCH, andDELETErequests are secured by default - You must pass a
formTokeninput value to be validated (CSRF protection)
Tina4 Python makes this simple and automatic, with no manual validation required in your routes.
<!-- @todo this implies that there is a single token stored in session that is used for validation for every request. It that really how it works? -->
Secure by Default
Tina4 generates a unique, signed token per session and validates it on every write request. Invalid tokens return a 403 Forbidden automatically.
Basic Form Handling {#basic-forms}#
Route Setup#
from tina4_python import get, postโ@get("/contact")async def contact_form(request, response): return response.render("contact.twig")โ@post("/contact")async def submit_contact(request, response): # Token already validated โ proceed safely! name = request.body.get("name", "") email = request.body.get("email", "") message = request.body.get("message", "")โ # Process: save to DB, send email, etc. # await send_email(email, message)โ return response.redirect("/thanks?success=true")Template (Twig/Jinja)#
<!-- templates/contact.twig --><form method="POST" action="/contact"> {{ form_token() }} <!-- Auto-generates <input name="formToken" value="..."> --> <div> <label for="name">Name</label> <input type="text" id="name" name="name" placeholder="Your name" required> </div> <div> <label for="email">Email</label> <input type="email" id="email" name="email" placeholder="your@email.com" required> </div> <div> <label for="message">Message</label> <textarea id="message" name="message" rows="5" required></textarea> </div> <button type="submit">Send Message</button></form>Generating Form Tokens: Three Ways {#form-tokens}#
There are three ways to get a formToken in Tina4 Python (aligned with PHP for consistency):
A. Using the Global Function form_token()#
Pass optional context for better security (e.g., page-specific tokens).
<!-- templates/login.twig --><form name="login" method="POST" action="/login"> <input type="text" name="username" placeholder="Username" required> {% set token = form_token({"page": "Login"}) %} <input type="hidden" name="formToken" value="{{ token }}"> <button type="submit">Login</button></form>B. Using the Filter | form_token#
Append ~RANDOM() to refresh the token on each render (prevents replay attacks).
<!-- templates/register.twig --><form name="register" method="POST" action="/register"> <input type="password" name="password" placeholder="Password" required> {{ ("Register" ~ RANDOM()) | form_token }} <!-- Outputs: <input type="hidden" name="formToken" value="fresh_token_here"> --> <button type="submit">Register</button></form>C. From Response Headers (FreshToken)#
For AJAX or meta tags, grab from the X-Fresh-Token header.
<!-- In your base layout --><meta name="fresh-token" content="{{ request.headers.get('FreshToken', '') }}">// In JSconst token = document.querySelector('meta[name="fresh-token"]').content;fetch('/api/save', { method: 'POST', headers: { 'Authorization': 'Bearer '+ token }, body: JSON.stringify({ data: 'value' })});File Uploads with Forms {#file-uploads}#
Add enctype="multipart/form-data" and handle via request.files.
<form method="POST" action="/upload" enctype="multipart/form-data"> {{ form_token() }} <input type="file" name="avatar" accept="image/*" multiple> <button type="submit">Upload Files</button></form>import base64import osโ@post("/upload")async def handle_upload(request, response): uploaded = request.files.get("avatar") if uploaded is None: return response("No file uploaded", 400)โ # Multiple files come as a list, single file as a dict file_list = uploaded if isinstance(uploaded, list) else [uploaded]โ for file in file_list: file_bytes = base64.b64decode(file["content"]) save_path = os.path.join("src", "public", "uploads", file["file_name"]) with open(save_path, "wb") as f: f.write(file_bytes)โ return response("Files uploaded!")Validation & Error Handling {#error-handling}#
Return errors and old input on failure.
@post("/register")async def register_user(request, response): errors = {} data = request.bodyโ if not data.get("email"): errors["email"] = "Email is required"โ if len(data.get("password", "")) < 8: errors["password"] = "Password must be at least 8 characters"โ if errors: return response.render("register.twig", { "errors": errors, "old": data # Repopulate form })โ # Success return response.redirect("/dashboard")In Twig:
<input type="email" name="email" value="{{ old.email|e if old else '' }}" required>{% if errors.email %} <span class="error">{{ errors.email }}</span>{% endif %}Disabling Protection (@noauth()) {#disabling-auth}#
Rarely needed, only for public webhooks.
@post("/webhook/payment")@noauth() # Skips token validationasync def payment_webhook(request, response): payload = request.body # Process without token return response("Received")Security Warning
Use @noauth() only for non-user endpoints like webhooks. Never on login/register forms!
Example: Full Login Flow {#full-example}#
Route#
@get("/login")async def login_page(request, response): return response.render("login.twig")โ@post("/login")async def process_login(request, response): username = request.body["username"] password = request.body["password"] if await validate_user(username, password): request.session.set("user", username) return response.redirect("/dashboard") return response.render("login.twig", {"error": "Invalid credentials"})<!-- @todo it is not obvious where the old is coming from. I know it is further up, but if someone jumps here for a working solution it might be confusing -->
Template#
<form method="POST" action="/login"> {{ ("Login" ~ RANDOM()) | form_token }} {% if error %} <p class="error">{{ error }}</p> {% endif %} <input type="text" name="username" value="{{ old.username|e if old else '' }}" required> <input type="password" name="password" required> <button>Login</button></form>