├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE ├── README.md ├── backend ├── .gitignore ├── config_template.json ├── dist │ ├── assets │ │ ├── index-DAWtUNlD.css │ │ └── index-Dqm_4jPr.js │ ├── favicon │ │ ├── apple-touch-icon.png │ │ ├── favicon-96x96.png │ │ ├── favicon.ico │ │ ├── favicon.svg │ │ ├── site.webmanifest │ │ ├── web-app-manifest-192x192.png │ │ └── web-app-manifest-512x512.png │ ├── index.html │ └── meta │ │ ├── logo.png │ │ ├── robots.txt │ │ └── sitemap.xml ├── generate_salts.py ├── index.html ├── main.py ├── requirements.txt ├── schema.sql └── wsgi.py ├── frontend ├── .gitignore ├── README.md ├── components.json ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public │ ├── favicon │ │ ├── apple-touch-icon.png │ │ ├── favicon-96x96.png │ │ ├── favicon.ico │ │ ├── favicon.svg │ │ ├── site.webmanifest │ │ ├── web-app-manifest-192x192.png │ │ └── web-app-manifest-512x512.png │ └── meta │ │ ├── logo.png │ │ ├── robots.txt │ │ └── sitemap.xml ├── src │ ├── App.css │ ├── App.tsx │ ├── assets │ │ └── react.svg │ ├── components │ │ └── ui │ │ │ ├── button.tsx │ │ │ └── input.tsx │ ├── index.css │ ├── lib │ │ └── utils.ts │ ├── main.tsx │ └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── logo-256px-no-padding.png ├── logo.png └── site-screenshot.png /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | buy_me_a_coffee: severin.hilbert 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.db 3 | *.ppk 4 | config.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Severin Hilbert 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

https://tnyr.me - Privacy-First URL Shortener

4 |
5 | 6 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 7 | 8 | A secure, self-hosted URL shortener with custom paswordless encryption. Perfect for privacy-conscious users and organizations. 9 | 10 | ![Screenshot](site-screenshot.png) 11 | 12 | ## Key Features 13 | 14 | 🔒 **Passwordless Encryption** 15 | 📡 **No Tracking** 16 | 🌐 **Modern Web Interface** 17 | 18 | ## Encryption Process 19 | 20 | 1. **ID Generation** 21 | - Unique random ID created for each link (e.g. `R53nSAg`) 22 | - Example: `google.com` → `tnyr.me/R53nSAg` 23 | 24 | 2. **Hashing** 25 | - Two Argon2 hashes are calculated by using different salts 26 | 27 | 3. **Storage** 28 | - Original URL encrypted with AES-256-GCM using Hash 2 29 | - Only Hash 1 (storage key) is saved in database 30 | 31 | ## Development Setup 32 | 33 | ### Prerequisites 34 | - Python 3.9+ 35 | - Node.js 16+ (for frontend development) 36 | 37 | ### Quick Start 38 | 1. **Clone Repository** 39 | ```bash 40 | git clone https://github.com/sevi-py/tnyr.me.git 41 | cd tnyr/backend 42 | ``` 43 | 44 | 2. **Install Dependencies** 45 | ```bash 46 | pip install -r requirements.txt 47 | ``` 48 | 49 | 3. **Configuration** 50 | Rename `config_template.json` to `config.json` 51 | Generate salts using `python generate_salts.py` 52 | Replace the placeholders with the salts you generated 53 | 54 | 4. **Start Server** 55 | ```bash 56 | python main.py 57 | ``` 58 | 59 | 5. Access at `http://localhost:5000` 60 | 61 | ## Frontend Development 62 | The backend serves pre-built frontend files. To modify the frontend: 63 | 64 | ```bash 65 | cd frontend 66 | npm install 67 | npm run build 68 | ``` 69 | 70 | ## Why Choose [tnyr.me](https://tnyr.me)? 71 | 72 | - **Privacy by Design**: We literally can't view your links 73 | - **No Tracking**: Zero cookies, analytics, or fingerprinting 74 | - **Self-Hostable**: Full control over your data 75 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | config.json 2 | urls.db -------------------------------------------------------------------------------- /backend/config_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "salts": { 3 | "salt1_var": "REPLACE_ME", 4 | "salt2_var": "REPLACE_ME" 5 | }, 6 | "argon2": { 7 | "time_cost": 3, 8 | "memory_cost": 65536, 9 | "parallelism": 1, 10 | "hash_length": 32 11 | }, 12 | "database": { 13 | "path": "urls.db" 14 | }, 15 | "id_generation": { 16 | "length": 10, 17 | "allowed_chars": "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ123456789" 18 | } 19 | } -------------------------------------------------------------------------------- /backend/dist/assets/index-DAWtUNlD.css: -------------------------------------------------------------------------------- 1 | *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground))}.absolute{position:absolute}.relative{position:relative}.bottom-4{bottom:1rem}.right-3{right:.75rem}.top-1\/2{top:50%}.mb-1{margin-bottom:.25rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.mt-1{margin-top:.25rem}.mt-12{margin-top:3rem}.mt-6{margin-top:1.5rem}.flex{display:flex}.inline-flex{display:inline-flex}.grid{display:grid}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-9{height:2.25rem}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-full{width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-6{gap:1.5rem}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(2rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem * var(--tw-space-y-reverse))}.whitespace-nowrap{white-space:nowrap}.break-all{word-break:break-all}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-input{border-color:hsl(var(--input))}.border-slate-600{--tw-border-opacity: 1;border-color:rgb(71 85 105 / var(--tw-border-opacity, 1))}.border-slate-700\/30{border-color:#3341554d}.border-slate-700\/50{border-color:#33415580}.bg-background{background-color:hsl(var(--background))}.bg-destructive{background-color:hsl(var(--destructive))}.bg-indigo-600{--tw-bg-opacity: 1;background-color:rgb(79 70 229 / var(--tw-bg-opacity, 1))}.bg-primary{background-color:hsl(var(--primary))}.bg-secondary{background-color:hsl(var(--secondary))}.bg-slate-700\/30{background-color:#3341554d}.bg-slate-700\/50{background-color:#33415580}.bg-slate-800\/50{background-color:#1e293b80}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.from-slate-900{--tw-gradient-from: #0f172a var(--tw-gradient-from-position);--tw-gradient-to: rgb(15 23 42 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.to-slate-800{--tw-gradient-to: #1e293b var(--tw-gradient-to-position)}.p-1{padding:.25rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-8{padding-left:2rem;padding-right:2rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.text-center{text-align:center}.text-2xl{font-size:1.5rem;line-height:2rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.text-destructive-foreground{color:hsl(var(--destructive-foreground))}.text-indigo-400{--tw-text-opacity: 1;color:rgb(129 140 248 / var(--tw-text-opacity, 1))}.text-primary{color:hsl(var(--primary))}.text-primary-foreground{color:hsl(var(--primary-foreground))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-secondary-foreground{color:hsl(var(--secondary-foreground))}.text-slate-100{--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity, 1))}.text-slate-300{--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.text-slate-400{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.underline-offset-4{text-underline-offset:4px}.shadow-xl{--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.ring-offset-background{--tw-ring-offset-color: hsl(var(--background))}.backdrop-blur-sm{--tw-backdrop-blur: blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}@keyframes enter{0%{opacity:var(--tw-enter-opacity, 1);transform:translate3d(var(--tw-enter-translate-x, 0),var(--tw-enter-translate-y, 0),0) scale3d(var(--tw-enter-scale, 1),var(--tw-enter-scale, 1),var(--tw-enter-scale, 1)) rotate(var(--tw-enter-rotate, 0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity, 1);transform:translate3d(var(--tw-exit-translate-x, 0),var(--tw-exit-translate-y, 0),0) scale3d(var(--tw-exit-scale, 1),var(--tw-exit-scale, 1),var(--tw-exit-scale, 1)) rotate(var(--tw-exit-rotate, 0))}}:root{font-family:Inter,system-ui,Avenir,Helvetica,Arial,sans-serif;line-height:1.5;font-weight:400;color-scheme:light dark;color:#ffffffde;background-color:#242424;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}a{font-weight:500;color:#646cff;text-decoration:inherit}a:hover{color:#535bf2}body{height:100vh;width:100vw;overflow-x:hidden;overflow-y:auto}h1{font-size:3.2em;line-height:1.1}button{border-radius:8px;border:1px solid transparent;padding:.6em 1.2em;font-size:1em;font-weight:500;font-family:inherit;background-color:#1a1a1a;cursor:pointer;transition:border-color .25s}button:hover{border-color:#646cff}button:focus,button:focus-visible{outline:4px auto -webkit-focus-ring-color}@media (prefers-color-scheme: light){:root{color:#213547;background-color:#fff}a:hover{color:#747bff}button{background-color:#f9f9f9}}.file\:border-0::file-selector-button{border-width:0px}.file\:bg-transparent::file-selector-button{background-color:transparent}.file\:text-sm::file-selector-button{font-size:.875rem;line-height:1.25rem}.file\:font-medium::file-selector-button{font-weight:500}.file\:text-foreground::file-selector-button{color:hsl(var(--foreground))}.placeholder\:text-muted-foreground::-moz-placeholder{color:hsl(var(--muted-foreground))}.placeholder\:text-muted-foreground::placeholder{color:hsl(var(--muted-foreground))}.hover\:bg-accent:hover{background-color:hsl(var(--accent))}.hover\:bg-destructive\/90:hover{background-color:hsl(var(--destructive) / .9)}.hover\:bg-indigo-700:hover{--tw-bg-opacity: 1;background-color:rgb(67 56 202 / var(--tw-bg-opacity, 1))}.hover\:bg-primary\/90:hover{background-color:hsl(var(--primary) / .9)}.hover\:bg-secondary\/80:hover{background-color:hsl(var(--secondary) / .8)}.hover\:bg-slate-600:hover{--tw-bg-opacity: 1;background-color:rgb(71 85 105 / var(--tw-bg-opacity, 1))}.hover\:bg-slate-600\/50:hover{background-color:#47556980}.hover\:text-accent-foreground:hover{color:hsl(var(--accent-foreground))}.hover\:text-indigo-300:hover{--tw-text-opacity: 1;color:rgb(165 180 252 / var(--tw-text-opacity, 1))}.hover\:text-slate-300:hover{--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.hover\:text-white:hover{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.hover\:underline:hover{text-decoration-line:underline}.focus-visible\:outline-none:focus-visible{outline:2px solid transparent;outline-offset:2px}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus-visible\:ring-ring:focus-visible{--tw-ring-color: hsl(var(--ring))}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width: 2px}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media (min-width: 768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:text-sm{font-size:.875rem;line-height:1.25rem}}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:size-4 svg{width:1rem;height:1rem}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0} 2 | -------------------------------------------------------------------------------- /backend/dist/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sevi-py/tnyr.me/f31891b395e48d32d4241422c8b110672848479d/backend/dist/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /backend/dist/favicon/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sevi-py/tnyr.me/f31891b395e48d32d4241422c8b110672848479d/backend/dist/favicon/favicon-96x96.png -------------------------------------------------------------------------------- /backend/dist/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sevi-py/tnyr.me/f31891b395e48d32d4241422c8b110672848479d/backend/dist/favicon/favicon.ico -------------------------------------------------------------------------------- /backend/dist/favicon/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/dist/favicon/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tnyr.me", 3 | "short_name": "tnyr", 4 | "icons": [ 5 | { 6 | "src": "/assets/favicon/web-app-manifest-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png", 9 | "purpose": "maskable" 10 | }, 11 | { 12 | "src": "/assets/favicon/web-app-manifest-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png", 15 | "purpose": "maskable" 16 | } 17 | ], 18 | "theme_color": "#ffffff", 19 | "background_color": "#ffffff", 20 | "display": "standalone" 21 | } -------------------------------------------------------------------------------- /backend/dist/favicon/web-app-manifest-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sevi-py/tnyr.me/f31891b395e48d32d4241422c8b110672848479d/backend/dist/favicon/web-app-manifest-192x192.png -------------------------------------------------------------------------------- /backend/dist/favicon/web-app-manifest-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sevi-py/tnyr.me/f31891b395e48d32d4241422c8b110672848479d/backend/dist/favicon/web-app-manifest-512x512.png -------------------------------------------------------------------------------- /backend/dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 31 | 32 | tnyr.me - Privacy friendly URL shortener 33 | 34 | 35 | 36 | 37 |
38 | 39 | 40 | -------------------------------------------------------------------------------- /backend/dist/meta/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sevi-py/tnyr.me/f31891b395e48d32d4241422c8b110672848479d/backend/dist/meta/logo.png -------------------------------------------------------------------------------- /backend/dist/meta/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /cgi-bin/ 3 | Disallow: /admin/ 4 | Sitemap: https://tnyr.me/sitemap.xml -------------------------------------------------------------------------------- /backend/dist/meta/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | https://tnyr.me/ 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /backend/generate_salts.py: -------------------------------------------------------------------------------- 1 | from secrets import token_hex 2 | 3 | print("SALT1:", token_hex(16)) 4 | print("SALT2:", token_hex(16)) -------------------------------------------------------------------------------- /backend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 31 | 32 | tnyr.me - Privacy friendly URL shortener 33 | 34 | 35 | 36 | 37 |
38 | 39 | 40 | -------------------------------------------------------------------------------- /backend/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import sqlite3 4 | import secrets 5 | from flask import Flask, request, jsonify, redirect 6 | from contextlib import closing 7 | from argon2.low_level import hash_secret_raw, Type 8 | from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes 9 | from cryptography.hazmat.primitives import padding 10 | from cryptography.hazmat.backends import default_backend 11 | 12 | # Load configuration 13 | with open('config.json') as f: 14 | config = json.load(f) 15 | 16 | app = Flask(__name__, static_folder='dist', static_url_path='/static') 17 | 18 | # Validate and load salts 19 | salt1_hex = config['salts']['salt1_var'] 20 | salt2_hex = config['salts']['salt2_var'] 21 | 22 | # Validate and convert salts 23 | try: 24 | SALT1 = bytes.fromhex(salt1_hex) 25 | SALT2 = bytes.fromhex(salt2_hex) 26 | if len(SALT1) != 16 or len(SALT2) != 16: 27 | raise ValueError("Salts must decode to 16 bytes") 28 | except ValueError as e: 29 | raise ValueError("Invalid salt format") from e 30 | 31 | # Database setup 32 | def get_db(): 33 | conn = sqlite3.connect(config['database']['path']) 34 | conn.row_factory = sqlite3.Row 35 | return conn 36 | 37 | # Initialize database 38 | def init_db(): 39 | with closing(get_db()) as conn: 40 | with app.open_resource('schema.sql', mode='r') as f: 41 | conn.cursor().executescript(f.read()) 42 | conn.commit() 43 | 44 | # ID generation setup 45 | def generate_id(): 46 | """Generate random ID based on config""" 47 | return ''.join( 48 | secrets.choice(config['id_generation']['allowed_chars']) 49 | for _ in range(config['id_generation']['length']) 50 | ) 51 | 52 | # Argon2 configuration 53 | def derive_key(id_bytes, salt): 54 | """Derive cryptographic key using Argon2 with config parameters""" 55 | return hash_secret_raw( 56 | secret=id_bytes, 57 | salt=salt, 58 | time_cost=config['argon2']['time_cost'], 59 | memory_cost=config['argon2']['memory_cost'], 60 | parallelism=config['argon2']['parallelism'], 61 | hash_len=config['argon2']['hash_length'], 62 | type=Type.ID 63 | ) 64 | 65 | def encrypt_url(key, plaintext): 66 | """Encrypt URL using AES-256-CBC""" 67 | # Validate key length 68 | if len(key) != 32: 69 | raise ValueError(f"Invalid key length: {len(key)} bytes (need 32)") 70 | 71 | iv = os.urandom(16) 72 | padder = padding.PKCS7(128).padder() 73 | padded_data = padder.update(plaintext.encode()) + padder.finalize() 74 | 75 | cipher = Cipher( 76 | algorithms.AES(key), 77 | modes.CBC(iv), 78 | backend=default_backend() 79 | ) 80 | encryptor = cipher.encryptor() 81 | ciphertext = encryptor.update(padded_data) + encryptor.finalize() 82 | 83 | return iv, ciphertext 84 | 85 | def decrypt_url(key, iv, ciphertext): 86 | """Decrypt URL using AES-256-CBC""" 87 | if len(key) != 32: 88 | raise ValueError(f"Invalid key length: {len(key)} bytes (need 32)") 89 | 90 | cipher = Cipher( 91 | algorithms.AES(key), 92 | modes.CBC(iv), 93 | backend=default_backend() 94 | ) 95 | decryptor = cipher.decryptor() 96 | padded_plaintext = decryptor.update(ciphertext) + decryptor.finalize() 97 | 98 | unpadder = padding.PKCS7(128).unpadder() 99 | plaintext = unpadder.update(padded_plaintext) + unpadder.finalize() 100 | 101 | return plaintext.decode() 102 | 103 | @app.route('/shorten', methods=['POST']) 104 | def shorten_url(): 105 | data = request.get_json() 106 | 107 | if not data or 'url' not in data: 108 | return jsonify({"error": "Missing URL"}), 400 109 | 110 | id = None 111 | url = data['url'] 112 | 113 | if not (url.startswith('https://') or url.startswith('http://') or url.startswith('magnet:')): 114 | url = 'http://' + url 115 | 116 | with get_db() as conn: 117 | # Generate unique ID and hash 118 | for _ in range(100): # Retry limit 119 | id = generate_id() 120 | id_bytes = id.encode() 121 | 122 | # Derive lookup hash 123 | lookup_hash = derive_key(id_bytes, SALT1).hex() 124 | 125 | # Check for collision 126 | cur = conn.execute( 127 | "SELECT 1 FROM urls WHERE lookup_hash = ?", 128 | (lookup_hash,) 129 | ) 130 | if not cur.fetchone(): 131 | break 132 | else: 133 | return jsonify({"error": "Failed to generate unique ID"}), 500 134 | 135 | # Derive encryption key 136 | encryption_key = derive_key(id_bytes, SALT2) 137 | 138 | # Encrypt URL 139 | iv, encrypted_url = encrypt_url(encryption_key, url) 140 | 141 | # Store in database 142 | conn.execute( 143 | "INSERT INTO urls (lookup_hash, iv, encrypted_url) VALUES (?, ?, ?)", 144 | (lookup_hash, iv, encrypted_url) 145 | ) 146 | conn.commit() 147 | 148 | return jsonify({"id": id}), 201 149 | 150 | @app.route('/') 151 | def redirect_url(id): 152 | id_bytes = id.encode() 153 | 154 | # Derive lookup hash 155 | lookup_hash = derive_key(id_bytes, SALT1).hex() 156 | 157 | # Retrieve from database 158 | with get_db() as conn: 159 | cur = conn.execute( 160 | "SELECT iv, encrypted_url FROM urls WHERE lookup_hash = ?", 161 | (lookup_hash,) 162 | ) 163 | row = cur.fetchone() 164 | 165 | if not row: 166 | return jsonify({"error": "Link not found"}), 404 167 | 168 | # Derive decryption key 169 | decryption_key = derive_key(id_bytes, SALT2) 170 | 171 | # Decrypt URL 172 | try: 173 | url = decrypt_url(decryption_key, row['iv'], row['encrypted_url']) 174 | except Exception as e: 175 | return jsonify({"error": "Decryption failed"}), 500 176 | 177 | return redirect(url, code=302) 178 | 179 | @app.route('/') 180 | def serve_react_app(): 181 | return app.send_static_file('index.html') 182 | 183 | @app.route("/robots.txt") 184 | def serve_robots_txt(): 185 | return app.send_static_file("meta/robots.txt") 186 | 187 | @app.route("/sitemap.xml") 188 | def serve_sitemap_xml(): 189 | return app.send_static_file("meta/sitemap.xml") 190 | 191 | @app.route('/') 192 | def serve_static_files(path): 193 | return app.send_static_file(path) 194 | 195 | if __name__ == '__main__': 196 | init_db() 197 | app.run(host='0.0.0.0', port=5000) -------------------------------------------------------------------------------- /backend/requirements.txt: -------------------------------------------------------------------------------- 1 | argon2-cffi==23.1.0 2 | argon2-cffi-bindings==21.2.0 3 | cryptography==36.0.2 4 | Flask==2.0.3 5 | Werkzeug==2.2.2 6 | gunicorn -------------------------------------------------------------------------------- /backend/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS urls ( 2 | lookup_hash TEXT PRIMARY KEY, 3 | iv BLOB NOT NULL, 4 | encrypted_url BLOB NOT NULL 5 | ); -------------------------------------------------------------------------------- /backend/wsgi.py: -------------------------------------------------------------------------------- 1 | from main import app # Import your Flask app instance 2 | 3 | if __name__ == "__main__": 4 | app.run() -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default tseslint.config({ 18 | languageOptions: { 19 | // other options... 20 | parserOptions: { 21 | project: ['./tsconfig.node.json', './tsconfig.app.json'], 22 | tsconfigRootDir: import.meta.dirname, 23 | }, 24 | }, 25 | }) 26 | ``` 27 | 28 | - Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` 29 | - Optionally add `...tseslint.configs.stylisticTypeChecked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: 31 | 32 | ```js 33 | // eslint.config.js 34 | import react from 'eslint-plugin-react' 35 | 36 | export default tseslint.config({ 37 | // Set the react version 38 | settings: { react: { version: '18.3' } }, 39 | plugins: { 40 | // Add the react plugin 41 | react, 42 | }, 43 | rules: { 44 | // other rules... 45 | // Enable its recommended rules 46 | ...react.configs.recommended.rules, 47 | ...react.configs['jsx-runtime'].rules, 48 | }, 49 | }) 50 | ``` 51 | -------------------------------------------------------------------------------- /frontend/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 31 | 32 | tnyr.me - Privacy friendly URL shortener 33 | 34 | 35 |
36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tnyr-frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@icons-pack/react-simple-icons": "^11.2.0", 14 | "@radix-ui/react-dropdown-menu": "^2.1.5", 15 | "@radix-ui/react-icons": "^1.3.2", 16 | "@radix-ui/react-slot": "^1.1.1", 17 | "axios": "^1.7.9", 18 | "class-variance-authority": "^0.7.1", 19 | "clsx": "^2.1.1", 20 | "lucide-react": "^0.474.0", 21 | "react": "^18.3.1", 22 | "react-dom": "^18.3.1", 23 | "tailwind-merge": "^2.6.0", 24 | "tailwindcss-animate": "^1.0.7" 25 | }, 26 | "devDependencies": { 27 | "@eslint/js": "^9.17.0", 28 | "@types/node": "^22.10.10", 29 | "@types/react": "^18.3.18", 30 | "@types/react-dom": "^18.3.5", 31 | "@vitejs/plugin-react": "^4.3.4", 32 | "autoprefixer": "^10.4.20", 33 | "eslint": "^9.17.0", 34 | "eslint-plugin-react-hooks": "^5.0.0", 35 | "eslint-plugin-react-refresh": "^0.4.16", 36 | "globals": "^15.14.0", 37 | "postcss": "^8.5.1", 38 | "tailwindcss": "^3.4.17", 39 | "typescript": "~5.6.2", 40 | "typescript-eslint": "^8.18.2", 41 | "vite": "^6.0.5" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /frontend/public/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sevi-py/tnyr.me/f31891b395e48d32d4241422c8b110672848479d/frontend/public/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /frontend/public/favicon/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sevi-py/tnyr.me/f31891b395e48d32d4241422c8b110672848479d/frontend/public/favicon/favicon-96x96.png -------------------------------------------------------------------------------- /frontend/public/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sevi-py/tnyr.me/f31891b395e48d32d4241422c8b110672848479d/frontend/public/favicon/favicon.ico -------------------------------------------------------------------------------- /frontend/public/favicon/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/public/favicon/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tnyr.me", 3 | "short_name": "tnyr", 4 | "icons": [ 5 | { 6 | "src": "/assets/favicon/web-app-manifest-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png", 9 | "purpose": "maskable" 10 | }, 11 | { 12 | "src": "/assets/favicon/web-app-manifest-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png", 15 | "purpose": "maskable" 16 | } 17 | ], 18 | "theme_color": "#ffffff", 19 | "background_color": "#ffffff", 20 | "display": "standalone" 21 | } -------------------------------------------------------------------------------- /frontend/public/favicon/web-app-manifest-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sevi-py/tnyr.me/f31891b395e48d32d4241422c8b110672848479d/frontend/public/favicon/web-app-manifest-192x192.png -------------------------------------------------------------------------------- /frontend/public/favicon/web-app-manifest-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sevi-py/tnyr.me/f31891b395e48d32d4241422c8b110672848479d/frontend/public/favicon/web-app-manifest-512x512.png -------------------------------------------------------------------------------- /frontend/public/meta/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sevi-py/tnyr.me/f31891b395e48d32d4241422c8b110672848479d/frontend/public/meta/logo.png -------------------------------------------------------------------------------- /frontend/public/meta/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /cgi-bin/ 3 | Disallow: /admin/ 4 | Sitemap: https://tnyr.me/sitemap.xml -------------------------------------------------------------------------------- /frontend/public/meta/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | https://tnyr.me/ 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /frontend/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import axios from 'axios'; 3 | import { Input } from './components/ui/input'; 4 | import { Button } from './components/ui/button'; 5 | import { Shield, Key, Hash, Lock, Copy, EyeOff, Github } from 'lucide-react'; 6 | import { SiBuymeacoffee } from '@icons-pack/react-simple-icons'; 7 | 8 | export default function App() { 9 | const [url, setUrl] = useState(''); 10 | const [shortened, setShortened] = useState(''); 11 | const [error, setError] = useState(''); 12 | const [loading, setLoading] = useState(false); 13 | 14 | // const isValidUrl = (string) => { 15 | // try { 16 | // new URL(string); 17 | // return true; 18 | // } catch (_) { 19 | // return false; 20 | // } 21 | // }; 22 | 23 | const handleSubmit = async (e: any) => { 24 | e.preventDefault(); 25 | // if (!isValidUrl(url)) { 26 | // setError('Please enter a valid URL'); 27 | // return; 28 | // } 29 | 30 | setLoading(true); 31 | try { 32 | const response = await axios.post('/shorten', { 33 | url: url 34 | }); 35 | const shortUrl = `tnyr.me/${response.data.id}`; 36 | setShortened(shortUrl); 37 | setError(''); 38 | } catch (err) { 39 | setError('Error shortening URL. Please try again.'); 40 | } 41 | setLoading(false); 42 | }; 43 | 44 | const copyToClipboard = () => { 45 | navigator.clipboard.writeText(shortened); 46 | }; 47 | 48 | return ( 49 |
50 |
51 |
52 |

tnyr.me

53 |

54 | Privacy-focused URL shortener with seamless encryption 55 |

56 |
57 | 58 |
59 |
60 |
61 | 62 |

63 | Your links are encrypted - we can't see your destination URLs or share your links! 64 |

65 |
66 |
67 | 68 |
69 | setUrl(e.target.value)} 73 | placeholder="Enter your long URL here" 74 | className="bg-slate-700/50 border-slate-600 text-lg h-14 rounded-xl" 75 | /> 76 | {error &&

{error}

} 77 | 78 | 85 |
86 | 87 | {shortened && ( 88 |
89 | 95 | {shortened} 96 | 97 | 105 |
106 | )} 107 |
108 |
109 | 110 |
111 |
112 |

113 | 114 | How We Protect Your Privacy 115 |

116 | 117 |
118 |
119 |
120 |
121 | 122 |
123 |
124 |

Zero-Knowledge Encryption

125 |

126 | Your URL is encrypted using AES-256 with a key derived from your unique link ID. 127 | Not even we can decrypt or view your original URL. 128 |

129 |
130 |
131 | 132 |
133 |
134 | 135 |
136 |
137 |

Secure Storage

138 |

139 | We generate two separate hashes - one for identification and another for encrypting the destination. Without the exact ID, the link is completely inaccessible. 140 |

141 |
142 |
143 |
144 | 145 |
146 |
147 |
148 | 149 |
150 |
151 |

Complete Anonymity

152 |

153 | There's no way to discover or list existing links. Each URL exists 154 | only for those who possess the unique ID. 155 |

156 |
157 |
158 | 159 |
160 |
161 | 162 |
163 |
164 |

Security Process

165 |

166 | We never log IP addresses, track users, or use cookies. Each request is completely anonymous - your browsing activity leaves no trace in our systems. 167 |

168 |
169 |
170 |
171 |
172 | 173 |
174 |

175 | 🔒 Important: Make sure to Bookmark your tnyr.me links safely - there's no way to recover lost IDs or access links without them. 176 |

177 |
178 |
179 |
180 | 181 | 199 |
200 | ); 201 | } -------------------------------------------------------------------------------- /frontend/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /frontend/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { X } from "lucide-react" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Input = React.forwardRef>( 7 | ({ className, type, value, onChange, ...props }, ref) => { 8 | return ( 9 |
10 | 21 | {value && ( 22 | 30 | )} 31 |
32 | ) 33 | } 34 | ) 35 | Input.displayName = "Input" 36 | 37 | export { Input } 38 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 7 | line-height: 1.5; 8 | font-weight: 400; 9 | 10 | color-scheme: light dark; 11 | color: rgba(255, 255, 255, 0.87); 12 | background-color: #242424; 13 | 14 | font-synthesis: none; 15 | text-rendering: optimizeLegibility; 16 | -webkit-font-smoothing: antialiased; 17 | -moz-osx-font-smoothing: grayscale; 18 | } 19 | 20 | a { 21 | font-weight: 500; 22 | color: #646cff; 23 | text-decoration: inherit; 24 | } 25 | a:hover { 26 | color: #535bf2; 27 | } 28 | 29 | body { 30 | height: 100vh; 31 | width: 100vw; 32 | overflow-x: hidden; 33 | overflow-y: auto; 34 | } 35 | 36 | h1 { 37 | font-size: 3.2em; 38 | line-height: 1.1; 39 | } 40 | 41 | button { 42 | border-radius: 8px; 43 | border: 1px solid transparent; 44 | padding: 0.6em 1.2em; 45 | font-size: 1em; 46 | font-weight: 500; 47 | font-family: inherit; 48 | background-color: #1a1a1a; 49 | cursor: pointer; 50 | transition: border-color 0.25s; 51 | } 52 | button:hover { 53 | border-color: #646cff; 54 | } 55 | button:focus, 56 | button:focus-visible { 57 | outline: 4px auto -webkit-focus-ring-color; 58 | } 59 | 60 | @media (prefers-color-scheme: light) { 61 | :root { 62 | color: #213547; 63 | background-color: #ffffff; 64 | } 65 | a:hover { 66 | color: #747bff; 67 | } 68 | button { 69 | background-color: #f9f9f9; 70 | } 71 | } 72 | 73 | 74 | 75 | @layer base { 76 | * { 77 | @apply border-border; 78 | } 79 | body { 80 | @apply bg-background text-foreground; 81 | 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /frontend/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.tsx' 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | darkMode: ["class"], 4 | content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"], 5 | theme: { 6 | extend: { 7 | borderRadius: { 8 | lg: 'var(--radius)', 9 | md: 'calc(var(--radius) - 2px)', 10 | sm: 'calc(var(--radius) - 4px)' 11 | }, 12 | colors: { 13 | background: 'hsl(var(--background))', 14 | foreground: 'hsl(var(--foreground))', 15 | card: { 16 | DEFAULT: 'hsl(var(--card))', 17 | foreground: 'hsl(var(--card-foreground))' 18 | }, 19 | popover: { 20 | DEFAULT: 'hsl(var(--popover))', 21 | foreground: 'hsl(var(--popover-foreground))' 22 | }, 23 | primary: { 24 | DEFAULT: 'hsl(var(--primary))', 25 | foreground: 'hsl(var(--primary-foreground))' 26 | }, 27 | secondary: { 28 | DEFAULT: 'hsl(var(--secondary))', 29 | foreground: 'hsl(var(--secondary-foreground))' 30 | }, 31 | muted: { 32 | DEFAULT: 'hsl(var(--muted))', 33 | foreground: 'hsl(var(--muted-foreground))' 34 | }, 35 | accent: { 36 | DEFAULT: 'hsl(var(--accent))', 37 | foreground: 'hsl(var(--accent-foreground))' 38 | }, 39 | destructive: { 40 | DEFAULT: 'hsl(var(--destructive))', 41 | foreground: 'hsl(var(--destructive-foreground))' 42 | }, 43 | border: 'hsl(var(--border))', 44 | input: 'hsl(var(--input))', 45 | ring: 'hsl(var(--ring))', 46 | chart: { 47 | '1': 'hsl(var(--chart-1))', 48 | '2': 'hsl(var(--chart-2))', 49 | '3': 'hsl(var(--chart-3))', 50 | '4': 'hsl(var(--chart-4))', 51 | '5': 'hsl(var(--chart-5))' 52 | } 53 | } 54 | } 55 | }, 56 | plugins: [require("tailwindcss-animate")], 57 | } 58 | 59 | -------------------------------------------------------------------------------- /frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true, 24 | 25 | "baseUrl": ".", 26 | "paths": { 27 | "@/*": [ 28 | "./src/*" 29 | ] 30 | } 31 | }, 32 | "include": ["src"] 33 | } 34 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ], 7 | "compilerOptions": { 8 | "baseUrl": ".", 9 | "paths": { 10 | "@/*": ["./src/*"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import react from "@vitejs/plugin-react" 3 | import { defineConfig } from "vite" 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | resolve: { 8 | alias: { 9 | "@": path.resolve(__dirname, "./src"), 10 | }, 11 | }, 12 | }) -------------------------------------------------------------------------------- /logo-256px-no-padding.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sevi-py/tnyr.me/f31891b395e48d32d4241422c8b110672848479d/logo-256px-no-padding.png -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sevi-py/tnyr.me/f31891b395e48d32d4241422c8b110672848479d/logo.png -------------------------------------------------------------------------------- /site-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sevi-py/tnyr.me/f31891b395e48d32d4241422c8b110672848479d/site-screenshot.png --------------------------------------------------------------------------------