Your one-stop-cake-shop for everything Freshly Baked has to offer

Compare changes

Choose any two refs to compare.

-16
menu/.sqlx/query-9eecf9b43e5458bc95fc45fe8e48d6da5edb50cdbf1e7a478faf310d6b9022ad.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "INSERT INTO direct (\"from\", \"to\", \"owner\") VALUES ($1, $2, $3) ON CONFLICT (\"from\") DO UPDATE SET \"to\" = EXCLUDED.to, \"owner\" = EXCLUDED.owner", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Varchar", 9 - "Varchar", 10 - "Varchar" 11 - ] 12 - }, 13 - "nullable": [] 14 - }, 15 - "hash": "9eecf9b43e5458bc95fc45fe8e48d6da5edb50cdbf1e7a478faf310d6b9022ad" 16 - }
+25
menu/.sqlx/query-b8fd68f2b748b100b6b594a1ce38ed07de8be8f5aefa059a2800b94f5b485a3d.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n WITH insertion AS (\n INSERT INTO direct (\"from\", \"to\", \"owner\")\n VALUES ($1, $2, $3)\n ON CONFLICT (\"from\")\n DO UPDATE SET \"to\" = EXCLUDED.to, \"owner\" = EXCLUDED.owner WHERE direct.to = $4\n RETURNING direct.from\n )\n SELECT direct.to FROM direct\n WHERE direct.from NOT IN (SELECT insertion.from FROM insertion) AND direct.from = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "to", 9 + "type_info": "Varchar" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Varchar", 15 + "Varchar", 16 + "Varchar", 17 + "Text" 18 + ] 19 + }, 20 + "nullable": [ 21 + false 22 + ] 23 + }, 24 + "hash": "b8fd68f2b748b100b6b594a1ce38ed07de8be8f5aefa059a2800b94f5b485a3d" 25 + }
+3
menu/.sqlx/query-b8fd68f2b748b100b6b594a1ce38ed07de8be8f5aefa059a2800b94f5b485a3d.json.license
··· 1 + SPDX-FileCopyrightText: 2026 Freshly Baked Cake 2 + 3 + SPDX-License-Identifier: CC0-1.0
+15
menu/.sqlx/query-caeb329e3f6c545eac6589ce5bc0bff894842512f8278db02ed7a50c193a92ea.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM direct WHERE direct.from = $1 AND direct.to = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "caeb329e3f6c545eac6589ce5bc0bff894842512f8278db02ed7a50c193a92ea" 15 + }
+3
menu/.sqlx/query-caeb329e3f6c545eac6589ce5bc0bff894842512f8278db02ed7a50c193a92ea.json.license
··· 1 + SPDX-FileCopyrightText: 2026 Freshly Baked Cake 2 + 3 + SPDX-License-Identifier: CC0-1.0
+127
menu/Cargo.lock
··· 489 489 ] 490 490 491 491 [[package]] 492 + name = "html-escape" 493 + version = "0.2.13" 494 + source = "registry+https://github.com/rust-lang/crates.io-index" 495 + checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" 496 + dependencies = [ 497 + "utf8-width", 498 + ] 499 + 500 + [[package]] 492 501 name = "http" 493 502 version = "1.4.0" 494 503 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 520 529 "http-body", 521 530 "pin-project-lite", 522 531 ] 532 + 533 + [[package]] 534 + name = "http-range-header" 535 + version = "0.4.2" 536 + source = "registry+https://github.com/rust-lang/crates.io-index" 537 + checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" 523 538 524 539 [[package]] 525 540 name = "httparse" ··· 673 688 ] 674 689 675 690 [[package]] 691 + name = "include_dir" 692 + version = "0.7.4" 693 + source = "registry+https://github.com/rust-lang/crates.io-index" 694 + checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" 695 + dependencies = [ 696 + "include_dir_macros", 697 + ] 698 + 699 + [[package]] 700 + name = "include_dir_macros" 701 + version = "0.7.4" 702 + source = "registry+https://github.com/rust-lang/crates.io-index" 703 + checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" 704 + dependencies = [ 705 + "proc-macro2", 706 + "quote", 707 + ] 708 + 709 + [[package]] 676 710 name = "indexmap" 677 711 version = "2.12.1" 678 712 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 788 822 version = "0.1.0" 789 823 dependencies = [ 790 824 "axum", 825 + "html-escape", 826 + "include_dir", 827 + "percent-encoding", 791 828 "serde", 792 829 "sqlx", 793 830 "tokio", 831 + "tower", 794 832 "tower-http", 795 833 "tower-layer", 834 + "tower-serve-static", 796 835 ] 797 836 798 837 [[package]] ··· 800 839 version = "0.3.17" 801 840 source = "registry+https://github.com/rust-lang/crates.io-index" 802 841 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 842 + 843 + [[package]] 844 + name = "mime_guess" 845 + version = "2.0.5" 846 + source = "registry+https://github.com/rust-lang/crates.io-index" 847 + checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" 848 + dependencies = [ 849 + "mime", 850 + "unicase", 851 + ] 803 852 804 853 [[package]] 805 854 name = "mio" ··· 907 956 version = "2.3.2" 908 957 source = "registry+https://github.com/rust-lang/crates.io-index" 909 958 checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 959 + 960 + [[package]] 961 + name = "pin-project" 962 + version = "1.1.10" 963 + source = "registry+https://github.com/rust-lang/crates.io-index" 964 + checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" 965 + dependencies = [ 966 + "pin-project-internal", 967 + ] 968 + 969 + [[package]] 970 + name = "pin-project-internal" 971 + version = "1.1.10" 972 + source = "registry+https://github.com/rust-lang/crates.io-index" 973 + checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" 974 + dependencies = [ 975 + "proc-macro2", 976 + "quote", 977 + "syn", 978 + ] 910 979 911 980 [[package]] 912 981 name = "pin-project-lite" ··· 1593 1662 ] 1594 1663 1595 1664 [[package]] 1665 + name = "tokio-util" 1666 + version = "0.7.18" 1667 + source = "registry+https://github.com/rust-lang/crates.io-index" 1668 + checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" 1669 + dependencies = [ 1670 + "bytes", 1671 + "futures-core", 1672 + "futures-sink", 1673 + "pin-project-lite", 1674 + "tokio", 1675 + ] 1676 + 1677 + [[package]] 1596 1678 name = "tower" 1597 1679 version = "0.5.2" 1598 1680 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1616 1698 dependencies = [ 1617 1699 "bitflags", 1618 1700 "bytes", 1701 + "futures-core", 1702 + "futures-util", 1619 1703 "http", 1704 + "http-body", 1705 + "http-body-util", 1706 + "http-range-header", 1707 + "httpdate", 1708 + "mime", 1709 + "mime_guess", 1710 + "percent-encoding", 1620 1711 "pin-project-lite", 1712 + "tokio", 1713 + "tokio-util", 1621 1714 "tower-layer", 1622 1715 "tower-service", 1716 + "tracing", 1623 1717 ] 1624 1718 1625 1719 [[package]] ··· 1629 1723 checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" 1630 1724 1631 1725 [[package]] 1726 + name = "tower-serve-static" 1727 + version = "0.1.1" 1728 + source = "registry+https://github.com/rust-lang/crates.io-index" 1729 + checksum = "148022c3d604d85a3b558ef7154aa90aaec5f9506beae64f6ad4e856306d287f" 1730 + dependencies = [ 1731 + "bytes", 1732 + "futures-util", 1733 + "http", 1734 + "http-body", 1735 + "http-body-util", 1736 + "include_dir", 1737 + "mime", 1738 + "mime_guess", 1739 + "percent-encoding", 1740 + "pin-project", 1741 + "tokio", 1742 + "tokio-util", 1743 + "tower-service", 1744 + ] 1745 + 1746 + [[package]] 1632 1747 name = "tower-service" 1633 1748 version = "0.3.3" 1634 1749 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1673 1788 checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" 1674 1789 1675 1790 [[package]] 1791 + name = "unicase" 1792 + version = "2.9.0" 1793 + source = "registry+https://github.com/rust-lang/crates.io-index" 1794 + checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" 1795 + 1796 + [[package]] 1676 1797 name = "unicode-bidi" 1677 1798 version = "0.3.18" 1678 1799 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1716 1837 "percent-encoding", 1717 1838 "serde", 1718 1839 ] 1840 + 1841 + [[package]] 1842 + name = "utf8-width" 1843 + version = "0.1.8" 1844 + source = "registry+https://github.com/rust-lang/crates.io-index" 1845 + checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" 1719 1846 1720 1847 [[package]] 1721 1848 name = "utf8_iter"
+6 -1
menu/Cargo.toml
··· 9 9 10 10 [dependencies] 11 11 axum = { version = "0.8.8", features = ["macros"] } 12 + html-escape = "0.2.13" 13 + include_dir = "0.7.4" 14 + percent-encoding = "2.3.2" 12 15 serde = { version = "1.0.228", features = ["derive"] } 13 16 sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls-ring-webpki", "postgres", "uuid", "macros"] } 14 17 tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] } 15 - tower-http = { version = "0.6.8", features = ["normalize-path"] } 18 + tower = "0.5.2" 19 + tower-http = { version = "0.6.8", features = ["fs", "normalize-path"] } 16 20 tower-layer = "0.3.3" 21 + tower-serve-static = "0.1.1"
+24
menu/src/html/create/conflict.html
··· 1 + <!-- 2 + SPDX-FileCopyrightText: 2026 Freshly Baked Cake 3 + 4 + SPDX-License-Identifier: MIT 5 + --> 6 + 7 + <html> 8 + <head> 9 + <title>Add shortlink</title> 10 + <link rel="stylesheet" href="/_/public/logo.css"/> 11 + <link rel="stylesheet" href="/_/public/colors.css"/> 12 + <link rel="stylesheet" href="/_/public/actions.css"/> 13 + </head> 14 + 15 + <body> 16 + <div id="logo"><div><span><span class="failure">Couldn't create</span> shortlink</span><img src="/_/public/logo.svg"></div></div> 17 + <p>You can't create a link from <a href="/{from:url}">{host}/{from}</a> to <a href="{to:attribute}">{to}</a> because <a href="/{from:url}">{host}/{from}</a> already goes to <a href="{current:attribute}">{current}</a>.</p> 18 + <ul> 19 + <li><a href="/_/create/do?from={from:url}&to={to:url}&current={current:url}">Overwrite it</a></li> 20 + <li><a href="/_/create?from={from:url}&to={to:url}">Back to editor</a></li> 21 + <li><a href="/">All shortlinks</a></li> 22 + </ul> 23 + </body> 24 + </html>
+23
menu/src/html/create/failure.html
··· 1 + <!-- 2 + SPDX-FileCopyrightText: 2026 Freshly Baked Cake 3 + 4 + SPDX-License-Identifier: MIT 5 + --> 6 + 7 + <html> 8 + <head> 9 + <title>Add shortlink</title> 10 + <link rel="stylesheet" href="/_/public/logo.css"/> 11 + <link rel="stylesheet" href="/_/public/colors.css"/> 12 + <link rel="stylesheet" href="/_/public/actions.css"/> 13 + </head> 14 + 15 + <body> 16 + <div id="logo"><div><span><span class="failure">Couldn't create</span> shortlink</span><img src="/_/public/logo.svg"></div></div> 17 + <p>There was an internal error when creating your shortlink. You may try again. If you keep experiencing this error, please contact Minion or Coded.</p> 18 + <ul> 19 + <li><a href="/_/create?from={from:url}&to={to:url}">Back to editor</a></li> 20 + <li><a href="/">All shortlinks</a></li> 21 + </ul> 22 + </body> 23 + </html>
+43
menu/src/html/create/success.html
··· 1 + <!-- 2 + SPDX-FileCopyrightText: 2026 Freshly Baked Cake 3 + 4 + SPDX-License-Identifier: MIT 5 + --> 6 + 7 + <html> 8 + <head> 9 + <title>Add shortlink</title> 10 + <link rel="stylesheet" href="/_/public/logo.css"/> 11 + <link rel="stylesheet" href="/_/public/colors.css"/> 12 + <link rel="stylesheet" href="/_/public/actions.css"/> 13 + </head> 14 + 15 + <body> 16 + <div id="logo"><div><span><span class="success">Created</span> shortlink</span><img src="/_/public/logo.svg"></div></div> 17 + <p>Your new shortlink is up!</p> 18 + <p><a href="/{from:url}">{host}/{from}</a> now goes to <a href="{to:attribute}">{to}</a></p> 19 + <ul> 20 + <li>Access it at <a href="/{from:url}">{host}/{from}</a> (<span id="copy" text="{host:attribute}/{from:attribute}">copy</span>)</li> 21 + <li><a href="/_/create">Create another one</a></li> 22 + <li><a href="/_/create?from={from:url}&to={to:url}&current={to:url}">Edit it</a></li> 23 + <li><a href="/_/delete/do?from={from:url}&current={to:url}">Delete it</a></li> 24 + <li><a href="/">All shortlinks</a></li> 25 + </ul> 26 + <script> 27 + const copy = document.getElementById("copy"); 28 + 29 + copy.onclick = () => { 30 + document.execCommand("copy"); 31 + }; 32 + 33 + copy.addEventListener("copy", event => { 34 + event.preventDefault(); 35 + if (event.clipboardData) { 36 + event.clipboardData.setData("text/plain", copy.getAttribute("text")); 37 + copy.classList.add('complete'); 38 + copy.textContent = 'copied'; 39 + } 40 + }); 41 + </script> 42 + </body> 43 + </html>
+13 -7
menu/src/html/create.html
··· 7 7 <html> 8 8 <head> 9 9 <title>Add shortlink</title> 10 + <link rel="stylesheet" href="/_/public/logo.css"/> 11 + <link rel="stylesheet" href="/_/public/forms.css"/> 10 12 </head> 11 13 12 14 <body> 13 - <form action="" method="post"> 14 - <div> 15 - <label for="from">When I go to {host}/</label><!-- 16 - --><input type="text" name="from" id="from" placeholder="kagi" value="{path}" required /> 17 - </div> 15 + <div id="logo"><div><span>Create shortlink</span><img src="/_/public/logo.svg"></div></div> 16 + <form action="/_/create/do" method="get"> 18 17 <div> 19 - <label for="to">Send me to </label> 20 - <input type="text" name="to" id="to" placeholder="https://kagi.com" required /> 18 + <div> 19 + <label for="from">When I go to {host}/</label><!-- 20 + --><input type="text" name="from" id="from" placeholder="kagi" value="{from:attribute}" required />... 21 + </div> 22 + <div> 23 + ...<label for="to">send me to </label> 24 + <input type="url" name="to" id="to" value="{to:attribute}" placeholder="https://kagi.com" required /> 25 + </div> 21 26 </div> 27 + <input type="hidden" name="current" id="current" value="{current:attribute}" /> 22 28 <input type="submit" value="Add shortlink" /> 23 29 </form> 24 30 </body>
+22
menu/src/html/delete/failure.html
··· 1 + <!-- 2 + SPDX-FileCopyrightText: 2026 Freshly Baked Cake 3 + 4 + SPDX-License-Identifier: MIT 5 + --> 6 + 7 + <html> 8 + <head> 9 + <title>Add shortlink</title> 10 + <link rel="stylesheet" href="/_/public/logo.css"/> 11 + <link rel="stylesheet" href="/_/public/colors.css"/> 12 + <link rel="stylesheet" href="/_/public/actions.css"/> 13 + </head> 14 + 15 + <body> 16 + <div id="logo"><div><span><span class="failure">Couldn't delete</span> shortlink</span><img src="/_/public/logo.svg"></div></div> 17 + <p>Shortlink deletions fail if the shortcut was changed before you asked for it to be deleted. Please try again from the "All shortlinks" page...</p> 18 + <ul> 19 + <li><a href="/">All shortlinks</a></li> 20 + </ul> 21 + </body> 22 + </html>
+23
menu/src/html/delete/success.html
··· 1 + <!-- 2 + SPDX-FileCopyrightText: 2026 Freshly Baked Cake 3 + 4 + SPDX-License-Identifier: MIT 5 + --> 6 + 7 + <html> 8 + <head> 9 + <title>Add shortlink</title> 10 + <link rel="stylesheet" href="/_/public/logo.css"/> 11 + <link rel="stylesheet" href="/_/public/colors.css"/> 12 + <link rel="stylesheet" href="/_/public/actions.css"/> 13 + </head> 14 + 15 + <body> 16 + <div id="logo"><div><span><span class="success">Deleted</span> shortlink</span><img src="/_/public/logo.svg"></div></div> 17 + <p><a href="/{from:url}">{host}/{from}</a> no longer goes to <a href="{current:attribute}">{current}</a></p> 18 + <ul> 19 + <li><a href="/_/create?from={from:url}&to={current:url}">Recreate it</a></li> 20 + <li><a href="/">All shortlinks</a></li> 21 + </ul> 22 + </body> 23 + </html>
+22
menu/src/html/public/actions.css
··· 1 + /* 2 + * SPDX-FileCopyrightText: 2026 Freshly Baked Cake 3 + * 4 + * SPDX-License-Identifier: MIT 5 + */ 6 + 7 + #copy { 8 + text-decoration: underline; 9 + color: blue; 10 + 11 + &:hover { 12 + cursor: pointer; 13 + } 14 + 15 + &.complete { 16 + color: purple; 17 + } 18 + 19 + &:active { 20 + color: red; 21 + } 22 + }
+13
menu/src/html/public/colors.css
··· 1 + /* 2 + * SPDX-FileCopyrightText: 2026 Freshly Baked Cake 3 + * 4 + * SPDX-License-Identifier: MIT 5 + */ 6 + 7 + .success { 8 + color: #64ff98; 9 + } 10 + 11 + .failure { 12 + color: #ff6464; 13 + }
+52
menu/src/html/public/forms.css
··· 1 + /* 2 + * SPDX-FileCopyrightText: 2026 Freshly Baked Cake 3 + * 4 + * SPDX-License-Identifier: MIT 5 + */ 6 + 7 + form { 8 + display: flex; 9 + flex-direction: column; 10 + align-items: center; 11 + } 12 + 13 + form > div { 14 + padding: 0.25em; 15 + } 16 + 17 + form > div > div { 18 + padding: 0.1em; 19 + } 20 + 21 + input { 22 + border: 0.1em solid black; 23 + border-radius: 0.25em; 24 + height: 1.5em; 25 + font-size: 1em; 26 + } 27 + 28 + input:invalid:not(:placeholder-shown):not(:focus), input:user-invalid { 29 + border: 0.1em solid red; 30 + } 31 + 32 + form div:has(> input) { 33 + &::after { 34 + content: ""; 35 + height: 0.75em; 36 + font-size: 0.75em; 37 + display: block; 38 + text-align: center; 39 + } 40 + 41 + &:has(:is(input:invalid:not(:placeholder-shown):not(:focus), input:user-invalid)) { 42 + &::after { 43 + height: auto; 44 + content: "You must fill this out"; 45 + color: red; 46 + } 47 + 48 + &:has(input[type="url"])::after { 49 + content: "You must enter a URL (including http:// or https://)"; 50 + } 51 + } 52 + }
+53
menu/src/html/public/logo.css
··· 1 + /* 2 + * SPDX-FileCopyrightText: 2026 Freshly Baked Cake 3 + * 4 + * SPDX-License-Identifier: MIT 5 + */ 6 + 7 + @import url('https://fonts.googleapis.com/css2?family=Delius&display=swap'); /* TODO: vendor this font */ 8 + 9 + html { 10 + display: flex; 11 + flex-direction: column; 12 + align-items: center; 13 + min-height: 100%; 14 + width: 100%; 15 + } 16 + 17 + body { 18 + display: flex; 19 + flex-direction: column; 20 + align-items: center; 21 + margin-top: 0; 22 + min-height: 100%; 23 + width: 80%; 24 + background: #e0faff; 25 + font-size: 1.5rem; 26 + font-family: sans-serif; 27 + } 28 + 29 + #logo { 30 + background: #144f5f; 31 + border-radius: 0 0 0.5rem 0.5rem; 32 + height: 5rem; 33 + display: flex; 34 + justify-content: center; 35 + width: 100%; 36 + margin-bottom: 1rem; 37 + } 38 + 39 + #logo > div { 40 + height: 100%; 41 + } 42 + 43 + #logo > div > span { 44 + color: #64e4ff; 45 + font-family: "Delius", cursive; 46 + font-size: 3rem; 47 + line-height: 5rem; 48 + vertical-align: top; 49 + } 50 + 51 + #logo > div > img { 52 + height: 100%; 53 + }
+37
menu/src/html/public/logo.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2048 2048" width="2048" height="2048"><defs><linearGradient id="13594572358447137094" x1="1023.9999999999964" y1="1290.6144863048703" x2="1855.3785056818951" y2="1290.6144863048703" gradientTransform="matrix(0.001202822,0,0,0.001875367,-1.231689288,-1.920375772)"><stop stop-color="#64e3ff" /><stop offset="1" stop-color="#196b9f" /></linearGradient></defs><g> 2 + <rect fill="#144e5e" x="0" y="0" width="2048" height="2048"/> 3 + <g> 4 + <g> 5 + <path d="M1023.9999999999964,1023.9999999999859 C1023.9999999999964,1023.9999999999859 1147.2733646355885,1557.2289726097542 1147.2733646355885,1557.2289726097542 C1147.2733646355885,1557.2289726097542 1726.371496179532,1557.2289726097542 1726.371496179532,1557.2289726097542 C1726.371496179532,1557.2289726097542 1855.3785056818951,1023.9999999999859 1855.3785056818951,1023.9999999999859 C1855.3785056818951,1023.9999999999859 1023.9999999999964,1023.9999999999859 1023.9999999999964,1023.9999999999859 Z" fill="url('#13594572358447137094')"/> 6 + </g> 7 + <g> 8 + <g transform="matrix(1,0,0,1,415.689252841,0)"> 9 + <path d="M659.0535042299737,1023.9999999999941 C659.0535042299737,1023.9999999999941 633.2521023295005,960.9299064655045 633.2521023295005,960.9299064655045 C633.2521023295005,960.9299064655045 659.0535042299732,889.2593456308584 659.0535042299732,889.2593456308584 C659.0535042299732,889.2593456308584 711.7114725606918,889.2593456308589 711.7114725606918,889.2593456308589 C711.7114725606918,889.2593456308589 689.6329435194218,826.1892520963702 689.6329435194218,826.1892520963702 C689.6329435194218,826.1892520963702 711.7114725606918,754.5186912617241 711.7114725606918,754.5186912617241 C711.7114725606918,754.5186912617241 784.36161869832,754.5186912617221 784.36161869832,754.5186912617221 C784.36161869832,754.5186912617221 767.4193922119493,691.4485977272336 767.4193922119493,691.4485977272336 C767.4193922119493,691.4485977272336 784.36161869832,619.7780368925883 784.36161869832,619.7780368925883 C784.36161869832,619.7780368925883 867.8557362803406,619.7780368925883 867.8557362803406,619.7780368925883 C867.8557362803406,619.7780368925883 856.8164717597062,556.7079433580998 856.8164717597062,556.7079433580998 C856.8164717597062,556.7079433580998 867.8557362803401,485.0373825234544 867.8557362803401,485.0373825234544 C867.8557362803401,485.0373825234544 1180.144263719638,485.0373825234544 1180.144263719638,485.0373825234544 C1180.144263719638,485.0373825234544 1191.183528240273,552.4077097080215 1191.183528240273,552.4077097080215 C1191.183528240273,552.4077097080215 1180.144263719638,619.7780368925883 1180.144263719638,619.7780368925883 C1180.144263719638,619.7780368925883 1263.6383813016655,619.7780368925883 1263.6383813016655,619.7780368925883 C1263.6383813016655,619.7780368925883 1280.580607788036,691.4485977272334 1280.580607788036,691.4485977272334 C1280.580607788036,691.4485977272334 1263.638381301666,754.5186912617221 1263.638381301666,754.5186912617221 C1263.638381301666,754.5186912617221 1336.288527439287,754.5186912617239 1336.288527439287,754.5186912617239 C1336.288527439287,754.5186912617239 1358.3670564805566,826.1892520963697 1358.3670564805566,826.1892520963697 C1358.3670564805566,826.1892520963697 1336.288527439287,889.2593456308589 1336.288527439287,889.2593456308589 C1336.288527439287,889.2593456308589 1388.946495770003,889.2593456308584 1388.946495770003,889.2593456308584 C1388.946495770003,889.2593456308584 1414.7478976704756,960.929906465504 1414.7478976704756,960.929906465504 C1414.7478976704756,960.929906465504 1388.946495770003,1023.9999999999934 1388.946495770003,1023.9999999999934 C1388.946495770003,1023.9999999999934 659.0535042299737,1023.9999999999941 659.0535042299737,1023.9999999999941 Z" fill="#fae59c"/> 10 + </g> 11 + <g transform="matrix(1,0,0,1,415.689252841,0)"> 12 + <g> 13 + <path d="M856.8164717597062,556.7079433580998 C856.8164717597062,556.7079433580998 1191.183528240273,552.4077097080215 1191.183528240273,552.4077097080215 C1191.183528240273,552.4077097080215 1180.1442637196387,619.7780368925883 1180.1442637196387,619.7780368925883 C1180.1442637196387,619.7780368925883 867.8557362803406,619.7780368925883 867.8557362803406,619.7780368925883 C867.8557362803406,619.7780368925883 856.8164717597062,556.7079433580998 856.8164717597062,556.7079433580998 Z" fill="#e7cc6e"/> 14 + </g> 15 + <g> 16 + <path d="M767.4193922119489,691.4485977272334 C767.4193922119489,691.4485977272334 1280.580607788036,691.4485977272332 1280.580607788036,691.4485977272332 C1280.580607788036,691.4485977272332 1263.6383813016655,754.5186912617219 1263.6383813016655,754.5186912617219 C1263.6383813016655,754.5186912617219 784.36161869832,754.5186912617223 784.36161869832,754.5186912617221 C784.3616186983197,754.5186912617219 767.4193922119489,691.4485977272334 767.4193922119489,691.4485977272334 Z" fill="#e7cc6e"/> 17 + </g> 18 + <g> 19 + <path d="M689.6329435194216,826.1892520963697 C689.6329435194216,826.1892520963697 1358.3670564805561,826.1892520963695 1358.3670564805561,826.1892520963695 C1358.3670564805561,826.1892520963695 1336.2885274392868,889.2593456308587 1336.2885274392868,889.2593456308587 C1336.2885274392868,889.2593456308587 711.7114725606916,889.2593456308589 711.7114725606916,889.2593456308589 C711.7114725606916,889.2593456308589 689.6329435194216,826.1892520963697 689.6329435194216,826.1892520963697 Z" fill="#e7cc6e"/> 20 + </g> 21 + <g> 22 + <path d="M633.2521023295001,960.929906465504 C633.2521023295001,960.929906465504 1414.7478976704751,960.9299064655036 1414.7478976704751,960.9299064655036 C1414.7478976704751,960.9299064655036 1388.9464957700027,1023.9999999999934 1388.9464957700027,1023.9999999999934 C1388.9464957700027,1023.9999999999934 659.0535042299734,1023.9999999999936 659.0535042299734,1023.9999999999936 C659.0535042299734,1023.9999999999936 633.2521023295001,960.929906465504 633.2521023295001,960.929906465504 Z" fill="#e7cc6e"/> 23 + </g> 24 + </g> 25 + </g> 26 + <g> 27 + <g> 28 + <path d="M122.52614469399941,1023.9999999999859 C122.52614469399941,1023.9999999999859 893.8839192448595,1023.9999999999859 893.8839192448595,1023.9999999999859" fill="none" stroke="#64e4ff" stroke-width="50" stroke-linecap="round" stroke-linejoin="round"/> 29 + </g> 30 + <g> 31 + <path d="M893.8839192448594,1024 C893.8839192448594,1024 656.0843589301605,1261.8 656.0843589301605,1261.8" fill="none" stroke="#64e4ff" stroke-width="50" stroke-linecap="round" stroke-linejoin="round"/> 32 + </g> 33 + <g> 34 + <path d="M893.8839192448594,1023.9999999999858 C893.8839192448594,1023.9999999999858 656.0843589301605,786.2004396852869 656.0843589301605,786.2004396852869" fill="none" stroke="#64e4ff" stroke-width="50" stroke-linecap="round" stroke-linejoin="round"/> 35 + </g> 36 + </g> 37 + </g></g></svg>
+3
menu/src/html/public/logo.svg.license
··· 1 + SPDX-FileCopyrightText: 2026 Freshly Baked Cake 2 + 3 + SPDX-License-Identifier: MIT
+334 -54
menu/src/main.rs
··· 1 1 // SPDX-FileCopyrightText: 2026 Freshly Baked Cake 2 2 // 3 3 // SPDX-License-Identifier: MIT 4 - 5 4 use axum::{ 6 - Form, Router, ServiceExt, 5 + Router, ServiceExt, 7 6 body::Body, 8 7 extract::{Path, Query, Request}, 9 8 http::{HeaderMap, Response, StatusCode}, 10 - response::{Html, IntoResponse, Redirect}, 11 - routing::{get, post}, 9 + response::{ErrorResponse, Html, IntoResponse, Redirect, Result}, 10 + routing::get, 12 11 }; 13 - use serde::Deserialize; 12 + use include_dir::{Dir, include_dir}; 13 + use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; 14 14 use sqlx::{Connection, PgConnection}; 15 + 16 + #[cfg(debug_assertions)] 17 + use std::fs; 18 + 15 19 use std::{collections::HashMap, env, ops::DerefMut, sync::OnceLock}; 16 20 use tokio::{ 17 21 sync::Mutex, 18 22 time::{Duration, sleep}, 19 23 }; 20 - use tower_http::normalize_path::NormalizePathLayer; 24 + use tower_http::{self, normalize_path::NormalizePathLayer}; 21 25 use tower_layer::Layer; 26 + use tower_serve_static; 27 + 28 + static PUBLIC_DIR: Dir<'static> = include_dir!("src/html/public"); 22 29 23 - fn template_html(html: &str, replacements: &HashMap<&str, Option<&str>>) -> String { 24 - let mut result = html.to_owned(); 25 - for (&text, &maybe_replacement) in replacements { 26 - if let Some(replacement) = maybe_replacement { 27 - result = result.replace(&("{".to_owned() + text + "}"), replacement) 28 - } 30 + #[cfg(debug_assertions)] 31 + static DEVELOPMENT: OnceLock<bool> = OnceLock::new(); 32 + 33 + fn template_html<'a>( 34 + mut html: String, 35 + replacements: HashMap<&str, Box<dyn 'a + FnOnce() -> Option<&'a str>>>, 36 + ) -> String { 37 + for (text, maybe_replacement) in replacements { 38 + let replacement = maybe_replacement().unwrap_or_else(|| ""); 39 + html = html.replace( 40 + &("{".to_owned() + text + "}"), 41 + &html_escape::encode_text(replacement), 42 + ); 43 + html = html.replace( 44 + &("{".to_owned() + text + ":attribute}"), 45 + &html_escape::encode_quoted_attribute(replacement), 46 + ); 47 + html = html.replace( 48 + &("{".to_owned() + text + ":url}"), 49 + &utf8_percent_encode(replacement, NON_ALPHANUMERIC).to_string(), 50 + ); 29 51 } 30 52 31 - result 53 + html 54 + } 55 + 56 + /// include_str, but if DEVELOPMENT then the string is dynamically fetched for easy reloading 57 + /// to support this, the string is *always* owned. 58 + #[cfg(debug_assertions)] 59 + macro_rules! include_String_dynamic { 60 + ($file:expr $(,)?) => { 61 + if (*DEVELOPMENT.get().unwrap()) { 62 + fs::read_to_string("src/".to_string() + $file) 63 + .expect(format!("Unable to read file {}", $file).as_str()) 64 + } else { 65 + include_str!($file).to_owned() 66 + } 67 + }; 68 + } 69 + #[cfg(not(debug_assertions))] 70 + macro_rules! include_String_dynamic { 71 + ($file:expr $(,)?) => { 72 + include_str!($file).to_owned() 73 + }; 32 74 } 33 75 34 76 #[derive(Debug)] ··· 76 118 } 77 119 78 120 async fn get_redirect_base(go: &str) -> Redirect { 79 - get_redirect("/_/create?path=", go).await 121 + get_redirect("/_/create?from=", go).await 80 122 } 81 123 82 124 async fn get_redirect_search(go: &str) -> Redirect { 83 125 get_redirect("https://kagi.com/search?q=", go).await 84 126 } 85 127 86 - async fn handle_root() -> String { 87 - "Hello, world!".to_string() 128 + async fn handle_root( 129 + headers: HeaderMap, 130 + #[cfg(debug_assertions)] Query(params): Query<HashMap<String, String>>, 131 + ) -> Result<String> { 132 + ensure_authenticated( 133 + &headers, 134 + #[cfg(debug_assertions)] 135 + &params, 136 + )?; 137 + Ok("Hello, world!".to_string()) 88 138 } 89 139 90 140 async fn handle_base(Path(go): Path<String>) -> Redirect { ··· 99 149 } 100 150 } 101 151 152 + #[derive(Clone)] 153 + enum StaticPageType { 154 + Create, 155 + CreateSuccess, 156 + CreateConflict, 157 + CreateFailure, 158 + DeleteSuccess, 159 + DeleteFailure, 160 + } 161 + 162 + async fn handle_static_page<'a>( 163 + page_type: StaticPageType, 164 + params: &'a HashMap<String, String>, 165 + headers: &'a HeaderMap, 166 + ) -> Result<Html<String>> { 167 + let auth_required = match page_type { 168 + _ => true, 169 + }; 170 + 171 + let html = match page_type { 172 + StaticPageType::Create => include_String_dynamic!("./html/create.html"), 173 + StaticPageType::CreateSuccess => include_String_dynamic!("./html/create/success.html"), 174 + StaticPageType::CreateConflict => include_String_dynamic!("./html/create/conflict.html"), 175 + StaticPageType::CreateFailure => include_String_dynamic!("./html/create/failure.html"), 176 + StaticPageType::DeleteSuccess => include_String_dynamic!("./html/delete/success.html"), 177 + StaticPageType::DeleteFailure => include_String_dynamic!("./html/delete/failure.html"), 178 + }; 179 + 180 + let username = if auth_required { 181 + Some(ensure_authenticated( 182 + headers, 183 + #[cfg(debug_assertions)] 184 + params, 185 + )?) 186 + } else { 187 + None 188 + }; 189 + 190 + let mut replacements: HashMap<&str, Box<dyn 'a + FnOnce() -> Option<&'a str>>> = HashMap::new(); 191 + replacements.insert( 192 + "host", 193 + Box::new(|| { 194 + Some(clean_host( 195 + headers 196 + .get("host") 197 + .and_then(|header| Some(header.to_str().unwrap_or_else(|_| "go"))) 198 + .unwrap_or_else(|| "go"), 199 + )) 200 + }), 201 + ); 202 + replacements.insert( 203 + "from", 204 + Box::new(|| params.get("from").and_then(|from| Some(from.as_str()))), 205 + ); 206 + replacements.insert( 207 + "to", 208 + Box::new(|| params.get("to").and_then(|to| Some(to.as_str()))), 209 + ); 210 + replacements.insert( 211 + "current", 212 + Box::new(|| { 213 + params 214 + .get("current") 215 + .and_then(|current| Some(current.as_str())) 216 + }), 217 + ); 218 + replacements.insert("username", Box::new(move || username)); 219 + 220 + let result = template_html(html, replacements); 221 + Ok(Html(result)) 222 + } 223 + 102 224 async fn handle_create_page( 103 225 Query(params): Query<HashMap<String, String>>, 104 226 headers: HeaderMap, 105 - ) -> Html<String> { 106 - Html(template_html( 107 - include_str!("./html/create.html"), 108 - &HashMap::from([ 109 - ( 110 - "host", 111 - Some(clean_host( 112 - headers 113 - .get("host") 114 - .and_then(|header| Some(header.to_str().unwrap_or_else(|_| "go"))) 115 - .unwrap_or_else(|| "go"), 116 - )), 117 - ), 118 - ( 119 - "path", 120 - params.get("path").and_then(|path| Some(path.as_str())), 121 - ), 122 - ]), 123 - )) 227 + ) -> Result<Html<String>> { 228 + handle_static_page(StaticPageType::Create, &params, &headers).await 229 + } 230 + async fn handle_create_success_page( 231 + Query(params): Query<HashMap<String, String>>, 232 + headers: HeaderMap, 233 + ) -> Result<Html<String>> { 234 + handle_static_page(StaticPageType::CreateSuccess, &params, &headers).await 235 + } 236 + async fn handle_create_conflict_page( 237 + Query(params): Query<HashMap<String, String>>, 238 + headers: HeaderMap, 239 + ) -> Result<Html<String>> { 240 + handle_static_page(StaticPageType::CreateConflict, &params, &headers).await 241 + } 242 + async fn handle_create_failure_page( 243 + Query(params): Query<HashMap<String, String>>, 244 + headers: HeaderMap, 245 + ) -> Result<impl IntoResponse> { 246 + handle_static_page(StaticPageType::CreateFailure, &params, &headers) 247 + .await 248 + .and_then(|html| Ok((StatusCode::INTERNAL_SERVER_ERROR, html))) 249 + } 250 + async fn handle_delete_success_page( 251 + Query(params): Query<HashMap<String, String>>, 252 + headers: HeaderMap, 253 + ) -> Result<Html<String>> { 254 + handle_static_page(StaticPageType::DeleteSuccess, &params, &headers).await 255 + } 256 + async fn handle_delete_failure_page( 257 + Query(params): Query<HashMap<String, String>>, 258 + headers: HeaderMap, 259 + ) -> Result<Html<String>> { 260 + handle_static_page(StaticPageType::DeleteFailure, &params, &headers).await 124 261 } 125 262 126 - #[derive(Deserialize)] 127 - struct Create { 128 - from: String, 129 - to: String, 263 + struct NotAuthenticated; 264 + impl IntoResponse for NotAuthenticated { 265 + fn into_response(self) -> axum::response::Response { 266 + return (StatusCode::UNAUTHORIZED, "Access over Tailscale only").into_response(); 267 + } 268 + } 269 + 270 + fn ensure_authenticated<'a>( 271 + headers: &'a HeaderMap, 272 + #[cfg(debug_assertions)] params: &'a HashMap<String, String>, 273 + ) -> Result<&'a str, ErrorResponse> { 274 + if let Some(user) = headers 275 + .get("X-Webauth-Login") 276 + .and_then(|header| header.to_str().ok()) 277 + { 278 + return Ok(user); 279 + } 280 + 281 + #[cfg(debug_assertions)] 282 + { 283 + if *DEVELOPMENT.get().unwrap() { 284 + if let Some(user) = params.get("dev_auth_as") { 285 + return Ok(user); 286 + } 287 + } 288 + } 289 + 290 + Err(NotAuthenticated {}.into()) 130 291 } 131 292 132 293 #[axum::debug_handler] 133 - async fn handle_create_post(headers: HeaderMap, Form(create): Form<Create>) -> Response<Body> { 134 - let Some(Ok(owner)) = headers 135 - .get("X-Webauth-Login") 136 - .and_then(|header| Some(header.to_str())) 137 - else { 138 - return (StatusCode::UNAUTHORIZED, "Access over Tailscale only").into_response(); 139 - }; 294 + async fn handle_create_do( 295 + headers: HeaderMap, 296 + Query(params): Query<HashMap<String, String>>, 297 + ) -> Result<Response<Body>> { 298 + let owner = ensure_authenticated( 299 + &headers, 300 + #[cfg(debug_assertions)] 301 + &params, 302 + )?; 303 + 304 + let from = params.get("from").ok_or("Missing from query")?; 305 + let to = params.get("to").ok_or("Missing to query")?; 306 + 307 + println!("Attempting to make go/{} -> {}", from, to); 140 308 141 309 let create_call = sqlx::query!( 142 - r#"INSERT INTO direct ("from", "to", "owner") VALUES ($1, $2, $3) ON CONFLICT ("from") DO UPDATE SET "to" = EXCLUDED.to, "owner" = EXCLUDED.owner"#, 143 - create.from.to_lowercase(), 144 - create.to, 310 + r#" 311 + WITH insertion AS ( 312 + INSERT INTO direct ("from", "to", "owner") 313 + VALUES ($1, $2, $3) 314 + ON CONFLICT ("from") 315 + DO UPDATE SET "to" = EXCLUDED.to, "owner" = EXCLUDED.owner WHERE direct.to = $4 316 + RETURNING direct.from 317 + ) 318 + SELECT direct.to FROM direct 319 + WHERE direct.from NOT IN (SELECT insertion.from FROM insertion) AND direct.from = $1 320 + "#, // Insert our URL, return a row with the same from that weren't updated (i.e. a conflict) 321 + from.to_lowercase(), 322 + to, 145 323 owner, 324 + params.get("current"), 325 + ) 326 + .fetch_optional( 327 + STATE 328 + .get() 329 + .expect("Server must be initialized before processing connections") 330 + .sqlx_connection 331 + .lock() 332 + .await 333 + .deref_mut(), 334 + ) 335 + .await; 336 + 337 + if let Ok(None) = &create_call { 338 + Ok(Redirect::to(&format!( 339 + "/_/create/success?from={}&to={}", 340 + utf8_percent_encode(&from, NON_ALPHANUMERIC).to_string(), 341 + utf8_percent_encode(&to, NON_ALPHANUMERIC).to_string(), 342 + )) 343 + .into_response()) 344 + } else if let Ok(Some(conflict)) = create_call { 345 + Ok(Redirect::to(&format!( 346 + "/_/create/conflict?from={}&to={}&current={}", 347 + utf8_percent_encode(&from, NON_ALPHANUMERIC).to_string(), 348 + utf8_percent_encode(&to, NON_ALPHANUMERIC).to_string(), 349 + utf8_percent_encode(&conflict.to, NON_ALPHANUMERIC).to_string(), 350 + )) 351 + .into_response()) 352 + } else { 353 + Ok(Redirect::to(&format!( 354 + "/_/create/failure?from={}&to={}", 355 + utf8_percent_encode(&from, NON_ALPHANUMERIC).to_string(), 356 + utf8_percent_encode(&to, NON_ALPHANUMERIC).to_string(), 357 + )) 358 + .into_response()) 359 + } 360 + } 361 + 362 + #[axum::debug_handler] 363 + async fn handle_delete_do( 364 + headers: HeaderMap, 365 + Query(params): Query<HashMap<String, String>>, 366 + ) -> Result<Response<Body>> { 367 + ensure_authenticated( 368 + &headers, 369 + #[cfg(debug_assertions)] 370 + &params, 371 + )?; 372 + 373 + let from = params.get("from").ok_or("Missing from query")?; 374 + let current = params.get("current").ok_or("Missing current query")?; 375 + 376 + println!("Attempting to delete go/{} -> {}", from, current); 377 + 378 + let delete_call = sqlx::query!( 379 + r#"DELETE FROM direct WHERE direct.from = $1 AND direct.to = $2"#, 380 + from.to_lowercase(), 381 + current, 146 382 ) 147 383 .execute( 148 384 STATE ··· 155 391 ) 156 392 .await; 157 393 158 - if create_call.is_ok() { 159 - Redirect::to(&format!("/_/created?path={}", create.from)).into_response() 394 + if let Ok(delete_result) = &delete_call 395 + && delete_result.rows_affected() > 0 396 + { 397 + Ok(Redirect::to(&format!( 398 + "/_/delete/success?from={}&current={}", 399 + utf8_percent_encode(&from, NON_ALPHANUMERIC).to_string(), 400 + utf8_percent_encode(&current, NON_ALPHANUMERIC).to_string(), 401 + )) 402 + .into_response()) 160 403 } else { 161 - Redirect::to(&format!("/_/createFailed?path={}", create.from)).into_response() 404 + Ok(Redirect::to(&format!( 405 + "/_/delete/failure?from={}&to={}", 406 + utf8_percent_encode(&from, NON_ALPHANUMERIC).to_string(), 407 + utf8_percent_encode(&current, NON_ALPHANUMERIC).to_string(), 408 + )) 409 + .into_response()) 162 410 } 163 411 } 164 412 ··· 168 416 169 417 #[tokio::main] 170 418 async fn main() { 419 + #[cfg(debug_assertions)] 420 + { 421 + DEVELOPMENT 422 + .set(env::var("DEVELOPMENT").is_ok_and(|value| value == "true")) 423 + .unwrap(); 424 + } 171 425 let mut connection = { 172 426 let mut maybe_connection; 173 427 let mut tries = 3; ··· 205 459 }) 206 460 .expect("Consistency issue: failed to set STATE - was it already set?"); 207 461 208 - let router = Router::new() 462 + let mut router = Router::new(); 463 + 464 + #[cfg(not(debug_assertions))] 465 + { 466 + router = router.nest_service("/_/public", tower_serve_static::ServeDir::new(&PUBLIC_DIR)); 467 + } 468 + 469 + #[cfg(debug_assertions)] 470 + { 471 + if *DEVELOPMENT.get().unwrap() { 472 + router = router.nest_service( 473 + "/_/public", 474 + tower_http::services::ServeDir::new("src/html/public"), 475 + ); 476 + } else { 477 + router = 478 + router.nest_service("/_/public", tower_serve_static::ServeDir::new(&PUBLIC_DIR)); 479 + } 480 + } 481 + 482 + router = router 209 483 .route("/", get(handle_root)) 210 484 .route("/_/create", get(handle_create_page)) 211 - .route("/_/create", post(handle_create_post)) 485 + .route("/_/create/success", get(handle_create_success_page)) 486 + .route("/_/create/conflict", get(handle_create_conflict_page)) 487 + .route("/_/create/failure", get(handle_create_failure_page)) 488 + .route("/_/create/do", get(handle_create_do)) 489 + .route("/_/delete/do", get(handle_delete_do)) 490 + .route("/_/delete/success", get(handle_delete_success_page)) 491 + .route("/_/delete/failure", get(handle_delete_failure_page)) 212 492 .route("/_/search", get(handle_search)) 213 493 .route("/_/{*route}", get(handle_404)) 214 494 .route("/{*go}", get(handle_base));