├── .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 | [](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 | 
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 |
86 |
87 | {shortened && (
88 |
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 | onChange?.({ target: { value: "" } } as React.ChangeEvent)}
27 | >
28 |
29 |
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
--------------------------------------------------------------------------------