eBay for krypto. https://kryptori.lu1.sh
0

Configure Feed

Select the types of activity you want to include in your feed.

Port in place from github, no need for history

+805
+4
.gitignore
··· 1 + kryptori.db 2 + venv 3 + __pycache__ 4 +
+35
Makefile
··· 1 + VENV_DIR = venv 2 + 3 + .PHONY: default 4 + default: run 5 + 6 + .PHONY: install 7 + install: 8 + nix develop 9 + 10 + run: 11 + @echo "Starting FastAPI server..." 12 + $(VENV_DIR)/bin/uvicorn main:app --reload 13 + 14 + .PHONY: format 15 + format: 16 + ruff check --fix . 17 + ruff format . 18 + 19 + # Lint the Python code using ruff 20 + .PHONY: lint 21 + lint: 22 + ruff check . 23 + 24 + .PHONY: test 25 + test: 26 + pytest 27 + 28 + # Clean up virtual environment and cache 29 + .PHONY: clean 30 + clean: 31 + @echo "Cleaning up..." 32 + rm -rf $(VENV_DIR) 33 + rm -rf __pycache__ 34 + rm -rf .pytest_cache 35 +
+76
README.md
··· 1 + ## Note: Being ported to Rust rq 2 + 3 + # Kryptori 4 + 5 + tori.fi, ebay, etc., but for exchanging cryptocurrencies and fiat P2P. 6 + 7 + LocalMonero is down, LocalBitcoins is down, somebody needs to pick up the 8 + slack. 9 + 10 + This codebase aims to create a **not for profit** platform for users to 11 + exchange crypto with random people online through public advertisements. This 12 + platform will not initially be an escrow service. 13 + 14 + For example, say I want to get more Monero. It's annoying to go through a 15 + third-party cryptocurrency to go from fiat currency -> Bitcoin (for example) -> 16 + Monero, all the while losing bits of my money along the way. I would rather 17 + find someone who's willing to just do an exchange of their Monero for my cash, 18 + either by mail or in person (yes, I'm brave). 19 + 20 + The ads on this platform will look like this: 21 + - I have 180EUR, I want 1XMR (in person, Uusimaa, Finland) 22 + - I have 0.003BTC, I want 290EUR (cash by mail, Europe) 23 + - I have the latest Thinkpad with xyz specs, I want 5XMR (in person or by mail, 24 + Stockholm, Sweden) etc. 25 + 26 + Then the interested parties will iron out the details of their exchange via 27 + chat. That's the make-or-break of this platform, relying on others to conduct 28 + themselves honorably and honestly - but it's up to each willing adult to use 29 + their best judgement. 30 + 31 + What do I get out of it, you ask? First of all I literally myself want to 32 + exchange currencies, second of all I guess I'll chuck a donation link on the 33 + site somewhere.. 34 + 35 + ## The requirements/constraints of this codebase 36 + 37 + - no accounts, no logging of user data, full stop. 38 + - must encourage users' self-sufficiency and liberty. No hosted Bitcoin 39 + wallets, but good instructions on how to make one's own. 40 + - users who post an ad must be able to come back and manage the advertisements 41 + they make. 42 + - web server: full functionality with HTML only. (With enhanced functionality 43 + with JS enabled?) 44 + 45 + Here's how I imagine the process to go: 46 + 47 + 1. A user posts an ad, and recieves a token via email where they can manage 48 + said ad. 49 + 2. An interested party clicks that they're interested, and is prompted to give 50 + a reply and their email address to the ad poster. 51 + 3. This site sends that interested party reply+caller email to the ad poster 52 + user via the poster's email. 53 + 4. The poster gets in touch with the interested party and sorts everything out 54 + with them via email between themselves. 55 + 5. Transaction occurs or doesn't, between themselves. The poster takes down or 56 + edits the ad via their token they got sent to their email originally. 57 + 58 + ## Run the application 59 + 60 + `nix develop` then `make run` or `uvicorn main:app --reload` 61 + 62 + ## Models 63 + 64 + ### Advertisement 65 + 66 + | Field | Description | 67 + |-----------------------|----------------------------------------------------------------------------------------------------------------------------------------| 68 + | **id** | | 69 + | **created_at** | | 70 + | **updated_at** | | 71 + | **title** | | 72 + | **description** | | 73 + | **active** | Whether the ad has been activated yet by user, proving they're alive by navigating to their ad management page via unique token param. | 74 + | **owner_email** | To send to only, never to show on the site. | 75 + | **owner_token** | So that there's a way for the OP to manage the ad after posting. | 76 +
+61
flake.lock
··· 1 + { 2 + "nodes": { 3 + "flake-utils": { 4 + "inputs": { 5 + "systems": "systems" 6 + }, 7 + "locked": { 8 + "lastModified": 1731533236, 9 + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 + "owner": "numtide", 11 + "repo": "flake-utils", 12 + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 + "type": "github" 14 + }, 15 + "original": { 16 + "owner": "numtide", 17 + "repo": "flake-utils", 18 + "type": "github" 19 + } 20 + }, 21 + "nixpkgs": { 22 + "locked": { 23 + "lastModified": 1737632463, 24 + "narHash": "sha256-38J9QfeGSej341ouwzqf77WIHAScihAKCt8PQJ+NH28=", 25 + "owner": "NixOS", 26 + "repo": "nixpkgs", 27 + "rev": "0aa475546ed21629c4f5bbf90e38c846a99ec9e9", 28 + "type": "github" 29 + }, 30 + "original": { 31 + "owner": "NixOS", 32 + "ref": "nixos-unstable", 33 + "repo": "nixpkgs", 34 + "type": "github" 35 + } 36 + }, 37 + "root": { 38 + "inputs": { 39 + "flake-utils": "flake-utils", 40 + "nixpkgs": "nixpkgs" 41 + } 42 + }, 43 + "systems": { 44 + "locked": { 45 + "lastModified": 1681028828, 46 + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 + "owner": "nix-systems", 48 + "repo": "default", 49 + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 + "type": "github" 51 + }, 52 + "original": { 53 + "owner": "nix-systems", 54 + "repo": "default", 55 + "type": "github" 56 + } 57 + } 58 + }, 59 + "root": "root", 60 + "version": 7 61 + }
+59
flake.nix
··· 1 + { 2 + description = "Kryptori environment"; 3 + 4 + inputs = { 5 + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 + flake-utils.url = "github:numtide/flake-utils"; 7 + }; 8 + 9 + outputs = { self, nixpkgs, flake-utils }: 10 + flake-utils.lib.eachDefaultSystem (system: 11 + let 12 + pkgs = import nixpkgs { inherit system; }; 13 + python = pkgs.python312; 14 + in 15 + rec { 16 + devShell = pkgs.mkShell { 17 + buildInputs = [ 18 + python 19 + python.pkgs.setuptools 20 + python.pkgs.wheel 21 + python.pkgs.uv 22 + ]; 23 + 24 + shellHook = '' 25 + echo "Entering Python development environment..." 26 + export PYTHONPATH=$PWD 27 + # Create a virtualenv inside the shell if it doesn't exist 28 + if [ ! -d "venv" ]; then 29 + uv venv venv 30 + fi 31 + 32 + # Activate the virtual environment 33 + source venv/bin/activate 34 + 35 + # Install dependencies from requirements.txt 36 + if [ -f requirements.txt ]; then 37 + uv pip install -r requirements.txt 38 + fi 39 + 40 + echo "All dependencies installed via uv." 41 + ''; 42 + }; 43 + 44 + packages.default = python.pkgs.buildPythonPackage { 45 + pname = "kryptori"; 46 + version = "0.1.0"; 47 + 48 + src = ./.; 49 + 50 + propagatedBuildInputs = []; 51 + 52 + # Optional: If you need to run tests 53 + # checkPhase = '' 54 + # pytest tests/ 55 + # ''; 56 + }; 57 + }); 58 + } 59 +
+345
main.py
··· 1 + import os 2 + from fastapi import FastAPI, Request, Form, HTTPException 3 + from fastapi.responses import HTMLResponse, RedirectResponse 4 + from jinja2 import Template 5 + import sqlite3 6 + import uuid 7 + import smtplib 8 + 9 + from email.message import EmailMessage 10 + 11 + app = FastAPI() 12 + 13 + DATABASE = "kryptori.db" 14 + 15 + 16 + def create_tables_if_not_exist(): 17 + conn = sqlite3.connect(DATABASE) 18 + cursor = conn.cursor() 19 + 20 + create_advertisement_table_sql = """ 21 + CREATE TABLE IF NOT EXISTS Advertisement ( 22 + id INTEGER PRIMARY KEY AUTOINCREMENT, 23 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, 24 + updated_at TEXT, 25 + title TEXT NOT NULL, 26 + description TEXT NOT NULL, 27 + owner_email TEXT NOT NULL, 28 + active INT NOT NULL DEFAULT 0, 29 + owner_token TEXT NOT NULL UNIQUE 30 + ); 31 + """ 32 + cursor.execute(create_advertisement_table_sql) 33 + conn.commit() 34 + conn.close() 35 + 36 + 37 + def create_advertisement( 38 + title: str, 39 + description: str, 40 + owner_email: str, 41 + ): 42 + title = title.strip()[0:1000] 43 + description = description.strip()[0:1000] 44 + owner_email = owner_email.strip()[0:1000] 45 + owner_token = uuid.uuid4() 46 + conn = sqlite3.connect(DATABASE) 47 + cursor = conn.cursor() 48 + 49 + insert_sql = """ 50 + INSERT INTO Advertisement (title, description, owner_email, owner_token) 51 + VALUES (?, ?, ?, ?) 52 + """ 53 + 54 + cursor.execute( 55 + insert_sql, 56 + (title, description, owner_email, str(owner_token)), 57 + ) 58 + conn.commit() 59 + conn.close() 60 + 61 + msg = EmailMessage() 62 + msg["From"] = os.environ.get("EMAIL") 63 + msg["To"] = owner_email 64 + msg["Subject"] = f"New ad created: {title}" 65 + msg.set_content( 66 + ( 67 + f"Thanks for making a new ad!\n" 68 + f"Title: {title}\n" 69 + f"Description: {description}\n" 70 + "Before it goes live, you must go activate the ad by loading the webpage:\n" 71 + f"kryptori.lu1.sh/manage-ad?token={owner_token}\n\n " 72 + f"The token for this ad is: {owner_token}\n" 73 + "and that's what you can use to update and delete the ad." 74 + ) 75 + ) 76 + 77 + # TODO: send via own email server 78 + s = smtplib.SMTP('smtp.gmail.com', 587) 79 + s.ehlo() 80 + s.starttls() 81 + s.ehlo() 82 + s.login(os.environ.get("EMAIL", ""), os.environ.get("PASSWORD", "")) 83 + s.send_message(msg) 84 + s.quit() 85 + 86 + 87 + def fetch_advertisements_html(): 88 + conn = sqlite3.connect(DATABASE) 89 + cursor = conn.cursor() 90 + cursor.execute( 91 + "SELECT id, title, description, created_at, updated_at FROM Advertisement WHERE active = 1 ORDER BY created_at DESC;" 92 + ) 93 + ads = cursor.fetchall() 94 + conn.close() 95 + 96 + html = "<div>" 97 + for id, title, description, created_at, updated_at in ads: 98 + html += f"""<div class="ad-container"> 99 + <details> 100 + <summary>{title}</summary> 101 + <i>Created on {created_at}{f", updated on {updated_at}" if updated_at is not None else ""}</i> 102 + <p style="margin-left: 50px;">{description}</p> 103 + <form action="/send-message" method="post"> 104 + <span>Get in touch with the poster!</span> 105 + <input type="hidden" id="ad_id" name="ad_id" value="{id}" required> 106 + 107 + <div style="margin-top: 4px;"> 108 + <label for="user_email"> 109 + <b>Your email</b> 110 + <br /> 111 + This won't be stored at all, it's just to tell the poster who to reply to. 112 + <br /> 113 + Check the <a target="_blank" href="https://github.com/lu1a/kryptori">source code</a> if in doubt. 114 + <br /> 115 + I'm open to suggestions on how to more formally prove that it's not 116 + <br /> 117 + saved behind the scenes or anything. 118 + </label> 119 + <br /> 120 + <input type="email" id="user_email" name="user_email" placeholder="abc@xyz.com" required> 121 + </div> 122 + 123 + <div class="input-container"> 124 + <label for="message"> 125 + <b>Message</b> 126 + </label> 127 + <br /> 128 + <textarea id="message" name="message" style="width: 100%; height: 6rem;" required></textarea> 129 + </div> 130 + 131 + <p> 132 + Remember, this will just send an email to the poster. 133 + <br /> 134 + Then you two will hash it out over email. 135 + <br /> 136 + Don't come crying to me if the poster doesn't get back to you, 137 + <br /> 138 + or if the poster grifts you or if you grift the poster. 139 + </p> 140 + <div class="input-container"> 141 + <button type="submit">Send Message</button> 142 + </div> 143 + </form> 144 + </details> 145 + </div>""" 146 + html += "</div>" 147 + return html 148 + 149 + 150 + def fetch_advertisement_by_token(token): 151 + conn = sqlite3.connect(DATABASE) 152 + cursor = conn.cursor() 153 + cursor.execute( 154 + "SELECT title, description, created_at, updated_at, owner_email FROM Advertisement WHERE owner_token = ? LIMIT 1", 155 + (token,), 156 + ) 157 + ad = cursor.fetchone() 158 + 159 + cursor.execute( 160 + "UPDATE Advertisement SET active = 1 WHERE owner_token = ?", 161 + (token,), 162 + ) 163 + conn.commit() 164 + conn.close() 165 + 166 + return ad 167 + 168 + 169 + create_tables_if_not_exist() 170 + 171 + 172 + @app.get("/", response_class=HTMLResponse) 173 + async def index(): 174 + with open("pages/index.html", "r") as f: 175 + index_page = f.read() 176 + 177 + ads_html = fetch_advertisements_html() 178 + template = Template(index_page) 179 + rendered_page = template.render(ads=ads_html) 180 + 181 + return rendered_page 182 + 183 + 184 + @app.post("/send-message") 185 + async def send_message( 186 + ad_id: int = Form(...), user_email: str = Form(...), message: str = Form(...) 187 + ): 188 + if user_email == "" or message == "": 189 + raise HTTPException(status_code=400, detail="Play nice!") 190 + 191 + conn = sqlite3.connect(DATABASE) 192 + cursor = conn.cursor() 193 + 194 + cursor.execute( 195 + "SELECT owner_email, title FROM Advertisement WHERE id = ?", (ad_id,) 196 + ) 197 + owner_email, title = cursor.fetchone() 198 + 199 + if not owner_email: 200 + conn.close() 201 + raise HTTPException(status_code=404, detail="Advertisement not found") 202 + 203 + conn.commit() 204 + conn.close() 205 + 206 + # TODO: use own mail server 207 + s = smtplib.SMTP('smtp.gmail.com', 587) 208 + s.ehlo() 209 + s.starttls() 210 + s.ehlo() 211 + 212 + msg_to_owner = EmailMessage() 213 + msg_to_owner["From"] = os.environ.get("EMAIL") 214 + msg_to_owner["To"] = owner_email 215 + msg_to_owner["Subject"] = f"New message about your ad: {title}" 216 + msg_to_owner.set_content( 217 + ( 218 + f"Here's a message from an interested party about your ad {title}!\n" 219 + f"Message:\n{message.strip()[0:1000]}\n" 220 + f"User email: {user_email.strip()[0:1000]}\n" 221 + "Please reply to their email address." 222 + ) 223 + ) 224 + s.login(os.environ.get("EMAIL", ""), os.environ.get("PASSWORD", "")) 225 + s.send_message(msg_to_owner) 226 + 227 + confirmation_msg_to_user = EmailMessage() 228 + confirmation_msg_to_user["From"] = os.environ.get("EMAIL") 229 + confirmation_msg_to_user["To"] = user_email.strip()[0:1000] 230 + confirmation_msg_to_user["Subject"] = "Confirmation: you sent a msg about an ad" 231 + confirmation_msg_to_user.set_content( 232 + f"You just sent a message in reply to this ad: {title}\n" 233 + f"Your message was: {message.strip()[0:1000]}\n\n" 234 + "Now you will wait for the ad poster to reply to you via email.\n" 235 + "Thanks for using kryptori!" 236 + ) 237 + s.send_message(confirmation_msg_to_user) 238 + s.quit() 239 + 240 + return RedirectResponse(url="/?success=true", status_code=303) 241 + 242 + 243 + @app.post("/create-ad", response_class=HTMLResponse) 244 + async def create_ad( 245 + title: str = Form(...), 246 + description: str = Form(...), 247 + owner_email: str = Form(...), 248 + ): 249 + if title == "" or description == "" or owner_email == "": 250 + raise HTTPException(status_code=400, detail="Play nice!") 251 + 252 + create_advertisement( 253 + title, 254 + description, 255 + owner_email, 256 + ) 257 + 258 + return RedirectResponse(url="/", status_code=303) 259 + 260 + 261 + @app.post("/update-ad", response_class=HTMLResponse) 262 + async def update_ad( 263 + title: str = Form(...), 264 + description: str = Form(...), 265 + token: str = Form(...), 266 + ): 267 + if title == "" or description == "": 268 + raise HTTPException(status_code=400, detail="Play nice!") 269 + 270 + conn = sqlite3.connect(DATABASE) 271 + cursor = conn.cursor() 272 + 273 + cursor.execute("SELECT * FROM Advertisement WHERE owner_token = ?", (token,)) 274 + existing_ad = cursor.fetchone() 275 + 276 + if not existing_ad: 277 + conn.close() 278 + raise HTTPException(status_code=404, detail="Advertisement not found") 279 + 280 + cursor.execute( 281 + """ 282 + UPDATE Advertisement 283 + SET title = ?, description = ?, updated_at = CURRENT_TIMESTAMP 284 + WHERE owner_token = ? 285 + """, 286 + (title.strip()[0:1000], description.strip()[0:1000], token), 287 + ) 288 + 289 + conn.commit() 290 + conn.close() 291 + 292 + return RedirectResponse( 293 + url=f"/manage-ad?token={token}&success=true", status_code=303 294 + ) 295 + 296 + 297 + @app.post("/delete-ad", response_class=HTMLResponse) 298 + async def delete_at( 299 + token: str = Form(...), 300 + ): 301 + conn = sqlite3.connect(DATABASE) 302 + cursor = conn.cursor() 303 + 304 + cursor.execute("DELETE FROM Advertisement WHERE owner_token = ?", (token,)) 305 + conn.commit() 306 + conn.close() 307 + 308 + return RedirectResponse(url="/?delete-success=true", status_code=303) 309 + 310 + 311 + @app.get("/manage-ad", response_class=HTMLResponse) 312 + async def manage_ad(request: Request): 313 + token = request.query_params.get("token") 314 + if not token: 315 + raise HTTPException(status_code=400, detail="Token parameter is required") 316 + 317 + with open("pages/manage_ad.html", "r") as f: 318 + manage_ad_page = f.read() 319 + 320 + ad = fetch_advertisement_by_token(token) 321 + if not ad: 322 + return RedirectResponse(url="/", status_code=303) 323 + title, description, created_at, updated_at, owner_email = ad 324 + template = Template(manage_ad_page) 325 + rendered_page = template.render( 326 + title=title, 327 + description=description, 328 + created_at=created_at, 329 + updated_at=updated_at, 330 + owner_email=owner_email, 331 + token=token, 332 + ) 333 + 334 + return rendered_page 335 + 336 + 337 + @app.get("/{path:path}", response_class=HTMLResponse) 338 + async def catch_all(path: str): 339 + return HTMLResponse(content=f"<h1>Unsupported URI: {path}</h1>", status_code=404) 340 + 341 + 342 + if __name__ == "__main__": 343 + import uvicorn 344 + 345 + uvicorn.run(app, host="0.0.0.0", port=8000)
+121
pages/index.html
··· 1 + <html> 2 + <head> 3 + <title>Kryptori</title> 4 + <style> 5 + .main-content details { 6 + border: 1px solid #aaa; 7 + border-radius: 4px; 8 + padding: 0.5em 0.5em 0; 9 + } 10 + 11 + .main-content summary { 12 + font-weight: bold; 13 + margin: -0.5em -0.5em 0; 14 + padding: 0.5em; 15 + cursor: pointer; 16 + } 17 + 18 + .main-content details[open] { 19 + padding: 0.5em; 20 + } 21 + 22 + .main-content details[open] summary { 23 + border-bottom: 1px solid #aaa; 24 + margin-bottom: 0.5em; 25 + } 26 + .ad-container { 27 + margin-top: 8px; 28 + } 29 + .input-container { 30 + margin-top: 24px; 31 + } 32 + @media (prefers-color-scheme: dark) { 33 + body { 34 + background-color: #33334d; 35 + color: white; 36 + } 37 + a { 38 + color: white; 39 + } 40 + } 41 + </style> 42 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 43 + </head> 44 + <body> 45 + <div class="site-info"> 46 + <h1>Kryptori</h1> 47 + <details> 48 + <summary style="cursor: pointer;">About</summary> 49 + <p> 50 + Like Ebay but for crypto etc. 51 + <br /> 52 + Not an escrow service, just getting two parties in touch 53 + so that they can exchange virtual and physical goods. 54 + <br /> 55 + The idea is that you and the other party conduct your affairs 56 + over email, because that encourages your liberty as opposed to trying to 57 + keep your data in some proprietary in-app chat. 58 + <br /> 59 + <br /> 60 + ...what, you don't like the UI?! 61 + Make a pull request to the source code then: <a href="https://github.com/lu1a/kryptori">github.com/lu1a/kryptori</a>. 62 + </p> 63 + </details> 64 + </div> 65 + <div class="main-content"> 66 + <details style="margin-top: 24px;"> 67 + <summary style="display: flex; align-items: center;"><h2>Create a new advertisement</h2></summary> 68 + <form action="/create-ad" method="post"> 69 + <div class="input-container"> 70 + <label for="owner_email"> 71 + <b>Your email</b> 72 + <br /> 73 + This is only stored in order to send user replies to later. 74 + <br /> 75 + When the ad is deleted, so is the email address. You can see in the <a target="_blank" href="https://github.com/lu1a/kryptori">source code</a> 76 + <br /> 77 + that the email isn't used anywhere else. I'm completely open to ideas 78 + <br /> 79 + on how better to prove that the email address isn't saved or used anywhere else. 80 + </label> 81 + <br /> 82 + <input type="email" id="owner_email" name="owner_email" placeholder="abc@xyz.com" required> 83 + </div> 84 + 85 + <div class="input-container"> 86 + <label for="title"> 87 + <b>Title</b> 88 + <br /> 89 + Be succinct. 90 + <br /> 91 + I recommend the format <i>Have: {amount} {currency}. Want: {amount} {currency}.</i> 92 + <br /> 93 + Eg. <i>Have: 5 XMR. Want: 0.011 BTC</i> 94 + <br /> 95 + <i>Have: 5 XMR. Want: a mini PC worth 5 XMR. Mail to Stockholm, Sweden.</i> 96 + </label> 97 + <br /> 98 + <input type="text" id="title" name="title" style="width: 48%;" required> 99 + </div> 100 + 101 + <div class="input-container"> 102 + <label for="description"> 103 + <b>Description</b> 104 + <br /> 105 + Here's where you actually describe details, caveats, etc. 106 + </label> 107 + <br /> 108 + <textarea id="description" name="description" style="width: 100%; height: 6rem;" required></textarea> 109 + </div> 110 + 111 + <div class="input-container"> 112 + <button type="submit">Create ad</button> 113 + </div> 114 + </form> 115 + </details> 116 + <h2>Advertisements</h2> 117 + {{ ads }} 118 + </div> 119 + </body> 120 + </html> 121 +
+61
pages/manage_ad.html
··· 1 + <html> 2 + <head> 3 + <title>Kryptori - manage ad</title> 4 + <style> 5 + @media (prefers-color-scheme: dark) { 6 + body { 7 + background-color: #33334d; 8 + color: white; 9 + } 10 + a { 11 + color: white; 12 + } 13 + } 14 + .input-container { 15 + margin-top: 24px; 16 + } 17 + </style> 18 + </head> 19 + <body> 20 + <h1>Kryptori</h1> 21 + <h2>Manage your ad</h2> 22 + <form action="/update-ad" method="post"> 23 + <div class="input-container"> 24 + <label for="title"><b>Title</b></label> 25 + <br /> 26 + <input type="text" id="title" name="title" value="{{ title }}" style="width: 48%;" required><br><br> 27 + </div> 28 + 29 + <i>Created on {{ created_at }}, updated on {{ updated_at }}</i> 30 + <p>User replies will be sent to <i>{{ owner_email }}</i></p> 31 + 32 + <div class="input-container"> 33 + <label for="description"><b>Description</b></label> 34 + <br /> 35 + <textarea id="description" name="description" style="width: 100%; height: 12rem;" required>{{ description }}</textarea> 36 + </div> 37 + 38 + <input type="hidden" id="token" name="token" value="{{ token }}" required /> 39 + 40 + <div class="input-container"> 41 + <button type="submit">Update ad</button> 42 + </div> 43 + </form> 44 + <details> 45 + <summary style="cursor: pointer;">Delete ad</summary> 46 + <form action="/delete-ad" method="post"> 47 + <input type="hidden" id="token" name="token" value="{{ token }}" required /> 48 + <div class="input-container"> 49 + <label for="delete"> 50 + Warning: since this page is HTML-only, there's no dialog or modal to confirm. 51 + <br /> 52 + If you hit this button the ad will be deleted right away. 53 + </label> 54 + <br /> 55 + <button type="submit" name="delete">Delete ad</button> 56 + </div> 57 + </form> 58 + </details> 59 + </body> 60 + </html> 61 +
+5
requirements.in
··· 1 + fastapi 2 + jinja2 3 + python-multipart 4 + ruff 5 + uvicorn
+38
requirements.txt
··· 1 + # This file was autogenerated by uv via the following command: 2 + # uv pip compile requirements.in -o requirements.txt 3 + annotated-types==0.7.0 4 + # via pydantic 5 + anyio==4.8.0 6 + # via starlette 7 + click==8.1.8 8 + # via uvicorn 9 + fastapi==0.115.7 10 + # via -r requirements.in 11 + h11==0.14.0 12 + # via uvicorn 13 + idna==3.10 14 + # via anyio 15 + jinja2==3.1.5 16 + # via -r requirements.in 17 + markupsafe==3.0.2 18 + # via jinja2 19 + pydantic==2.10.6 20 + # via fastapi 21 + pydantic-core==2.27.2 22 + # via pydantic 23 + python-multipart==0.0.20 24 + # via -r requirements.in 25 + ruff==0.9.3 26 + # via -r requirements.in 27 + sniffio==1.3.1 28 + # via anyio 29 + starlette==0.45.3 30 + # via fastapi 31 + typing-extensions==4.12.2 32 + # via 33 + # anyio 34 + # fastapi 35 + # pydantic 36 + # pydantic-core 37 + uvicorn==0.34.0 38 + # via -r requirements.in