| 1 | + | /target |
|
| 2 | + | *.sqlite |
|
| 3 | + | *.db |
|
| 4 | + | .env |
|
| 5 | + | .DS_Store |
| 1 | + | [workspace] |
|
| 2 | + | members = [ |
|
| 3 | + | "apps/sipp", |
|
| 4 | + | "apps/feeds", |
|
| 5 | + | "apps/parcels", |
|
| 6 | + | "apps/jotts", |
|
| 7 | + | "apps/og", |
|
| 8 | + | "apps/shrink", |
|
| 9 | + | "crates/auth", |
|
| 10 | + | ] |
|
| 11 | + | resolver = "3" |
|
| 12 | + | ||
| 13 | + | [profile.dist] |
|
| 14 | + | inherits = "release" |
|
| 15 | + | lto = "thin" |
|
| 16 | + | ||
| 17 | + | [workspace.dependencies] |
|
| 18 | + | # Core web stack |
|
| 19 | + | axum = "0.8.8" |
|
| 20 | + | tokio = { version = "1", features = ["full"] } |
|
| 21 | + | serde = { version = "1", features = ["derive"] } |
|
| 22 | + | serde_json = "1" |
|
| 23 | + | dotenvy = "0.15" |
|
| 24 | + | ||
| 25 | + | # HTTP / middleware |
|
| 26 | + | tower-http = "0.6.8" |
|
| 27 | + | ||
| 28 | + | # Assets |
|
| 29 | + | rust-embed = "8" |
|
| 30 | + | ||
| 31 | + | # Database |
|
| 32 | + | rusqlite = { version = "0.38", features = ["bundled"] } |
|
| 33 | + | nanoid = "0.4.0" |
|
| 34 | + | ||
| 35 | + | # Auth |
|
| 36 | + | subtle = "2" |
|
| 37 | + | rand = "0.8" |
|
| 38 | + | ||
| 39 | + | # Observability |
|
| 40 | + | tracing = "0.1" |
|
| 41 | + | tracing-subscriber = "0.3" |
|
| 42 | + | ||
| 43 | + | # Workspace crates |
|
| 44 | + | andromeda-auth = { path = "crates/auth" } |
| 1 | + | [package] |
|
| 2 | + | name = "feeds" |
|
| 3 | + | version = "0.1.0" |
|
| 4 | + | edition = "2024" |
|
| 5 | + | ||
| 6 | + | [dependencies] |
|
| 7 | + | axum = { workspace = true } |
|
| 8 | + | tokio = { workspace = true } |
|
| 9 | + | serde = { workspace = true } |
|
| 10 | + | serde_json = { workspace = true } |
|
| 11 | + | dotenvy = { workspace = true } |
|
| 12 | + | rust-embed = { workspace = true } |
|
| 13 | + | subtle = { workspace = true } |
|
| 14 | + | rand = { workspace = true } |
|
| 15 | + | andromeda-auth = { workspace = true } |
|
| 16 | + | askama = "0.13" |
|
| 17 | + | reqwest = { version = "0.12", features = ["json"] } |
|
| 18 | + | feed-rs = "2" |
|
| 19 | + | chrono = "0.4" |
|
| 20 | + | quick-xml = "0.37" |
|
| 21 | + | mime_guess = "2" |
|
| 22 | + | urlencoding = "2" |
| 1 | + | # Build from repo root: docker build -t feeds -f apps/feeds/Dockerfile . |
|
| 2 | + | FROM rust:1-slim-bookworm AS builder |
|
| 3 | + | WORKDIR /app |
|
| 4 | + | ||
| 5 | + | # Copy workspace manifests |
|
| 6 | + | COPY Cargo.toml Cargo.lock ./ |
|
| 7 | + | COPY crates/auth/Cargo.toml crates/auth/ |
|
| 8 | + | COPY apps/sipp/Cargo.toml apps/sipp/ |
|
| 9 | + | COPY apps/feeds/Cargo.toml apps/feeds/ |
|
| 10 | + | COPY apps/parcels/Cargo.toml apps/parcels/ |
|
| 11 | + | COPY apps/jotts/Cargo.toml apps/jotts/ |
|
| 12 | + | COPY apps/og/Cargo.toml apps/og/ |
|
| 13 | + | COPY apps/shrink/Cargo.toml apps/shrink/ |
|
| 14 | + | ||
| 15 | + | # Create stubs for dependency caching |
|
| 16 | + | RUN mkdir -p crates/auth/src && echo '' > crates/auth/src/lib.rs \ |
|
| 17 | + | && for app in sipp feeds parcels jotts og shrink; do \ |
|
| 18 | + | mkdir -p apps/$app/src && echo 'fn main() {}' > apps/$app/src/main.rs; \ |
|
| 19 | + | done |
|
| 20 | + | ||
| 21 | + | RUN cargo build --release -p feeds |
|
| 22 | + | ||
| 23 | + | # Copy real source |
|
| 24 | + | COPY crates/auth/src crates/auth/src |
|
| 25 | + | COPY apps/feeds/src apps/feeds/src |
|
| 26 | + | COPY apps/feeds/assets apps/feeds/assets |
|
| 27 | + | COPY apps/feeds/askama.toml apps/feeds/askama.toml |
|
| 28 | + | ||
| 29 | + | RUN touch apps/feeds/src/*.rs crates/auth/src/*.rs && cargo build --release -p feeds |
|
| 30 | + | ||
| 31 | + | FROM debian:bookworm-slim |
|
| 32 | + | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* |
|
| 33 | + | WORKDIR /app |
|
| 34 | + | COPY --from=builder /app/target/release/feeds ./feeds |
|
| 35 | + | EXPOSE 4555 |
|
| 36 | + | CMD ["./feeds"] |
| 1 | + | MIT License |
|
| 2 | + | ||
| 3 | + | Copyright (c) 2026 Steve Simkins |
|
| 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 | + |
| 1 | + | # Feeds |
|
| 2 | + | ||
| 3 | + |  |
|
| 4 | + | ||
| 5 | + | Minimal RSS Feeds |
|
| 6 | + | ||
| 7 | + | ## About |
|
| 8 | + | ||
| 9 | + | Feeds is a minimal RSS reader that mimics the original experience of RSS. It's just a list of posts. No categories, no marking a post read or unread, and there is no in-app reading. With this approach you have to read the post on the authors personal website and experience it in it's original context. While this may not work well if you have loads of news feeds, I personally love it for [my approach to blogs](https://blogfeeds.net). |
|
| 10 | + | ||
| 11 | + | This app is also MIT open sourced and designed to be self-hosted; fork the code and change it to your liking! |
|
| 12 | + | ||
| 13 | + | ## Usage |
|
| 14 | + | ||
| 15 | + | There are several built-in ways to source RSS feeds. |
|
| 16 | + | ||
| 17 | + | ### URL Query Param |
|
| 18 | + | ||
| 19 | + | Once you have the app running you can add the following to the URL to source an RSS feed: |
|
| 20 | + | ||
| 21 | + | ``` |
|
| 22 | + | ?url=https://bearblog.dev/discover/feed/ |
|
| 23 | + | ``` |
|
| 24 | + | ||
| 25 | + | You can also add multiple URLs by using commas to separate them: |
|
| 26 | + | ||
| 27 | + | ``` |
|
| 28 | + | ?urls=https://bearblog.dev/discover/feed/,https://bearblog.stevedylan.dev/feed/ |
|
| 29 | + | ``` |
|
| 30 | + | ||
| 31 | + | ### OPML File |
|
| 32 | + | ||
| 33 | + | If you save a `feeds.opml` file in the root of the project the app will automatically source it and fetch the posts for the feeds inside. |
|
| 34 | + | ||
| 35 | + | ### FreshRSS API |
|
| 36 | + | ||
| 37 | + | If neither of the above are provided the app will default to using a FreshRSS API instance. Simply run the following command: |
|
| 38 | + | ||
| 39 | + | ```bash |
|
| 40 | + | cp .env.sample .env |
|
| 41 | + | ``` |
|
| 42 | + | ||
| 43 | + | Then fill in the environment variables: |
|
| 44 | + | ||
| 45 | + | ``` |
|
| 46 | + | FRESHRSS_URL= |
|
| 47 | + | FRESHRSS_USERNAME= |
|
| 48 | + | FRESHRSS_PASSWORD= |
|
| 49 | + | ``` |
|
| 50 | + | ||
| 51 | + | ### Admin Panel |
|
| 52 | + | ||
| 53 | + | Feeds includes a password-protected admin panel at `/admin` for managing your FreshRSS subscriptions. Set the `ADMIN_PASSWORD` environment variable to enable it: |
|
| 54 | + | ||
| 55 | + | ``` |
|
| 56 | + | ADMIN_PASSWORD=your_secret_password |
|
| 57 | + | ``` |
|
| 58 | + | ||
| 59 | + | From the admin panel you can view your current subscriptions and add new feeds directly to your FreshRSS instance. |
|
| 60 | + | ||
| 61 | + | ### Feeds API |
|
| 62 | + | ||
| 63 | + | The `/feeds` endpoint exports your FreshRSS subscriptions in JSON or OPML format: |
|
| 64 | + | ||
| 65 | + | ``` |
|
| 66 | + | /feeds?format=json |
|
| 67 | + | /feeds?format=opml |
|
| 68 | + | ``` |
|
| 69 | + | ||
| 70 | + | ## Quickstart |
|
| 71 | + | ||
| 72 | + | 1. Make sure [Rust](https://www.rust-lang.org/tools/install) is installed |
|
| 73 | + | ||
| 74 | + | ```bash |
|
| 75 | + | rustc --version |
|
| 76 | + | ``` |
|
| 77 | + | ||
| 78 | + | 2. Clone and build |
|
| 79 | + | ||
| 80 | + | ```bash |
|
| 81 | + | git clone https://github.com/stevedylandev/feeds |
|
| 82 | + | cd feeds |
|
| 83 | + | cargo build |
|
| 84 | + | ``` |
|
| 85 | + | ||
| 86 | + | 3. Run the dev server |
|
| 87 | + | ||
| 88 | + | ```bash |
|
| 89 | + | cargo run |
|
| 90 | + | # Server running on http://localhost:4555 |
|
| 91 | + | ``` |
|
| 92 | + | ||
| 93 | + | ## Project Structure |
|
| 94 | + | ||
| 95 | + | The architecture is intentionally simple: |
|
| 96 | + | - **`src/main.rs`** - Axum server with routing, templates, and static asset serving |
|
| 97 | + | - **`src/feeds.rs`** - Feed fetching, OPML parsing, and FreshRSS API integration |
|
| 98 | + | - **`src/auth.rs`** - Session-based authentication with constant-time password verification |
|
| 99 | + | - **`src/models.rs`** - Data structures for feeds and FreshRSS responses |
|
| 100 | + | - **`src/templates/`** - Askama HTML templates |
|
| 101 | + | - **`assets/`** - Static assets embedded at compile time via `rust-embed` |
|
| 102 | + | ||
| 103 | + | ## Environment Variables |
|
| 104 | + | ||
| 105 | + | | Variable | Description | Required | |
|
| 106 | + | |---|---|---| |
|
| 107 | + | | `FRESHRSS_URL` | URL of your FreshRSS instance | For FreshRSS mode | |
|
| 108 | + | | `FRESHRSS_USERNAME` | FreshRSS username | For FreshRSS mode | |
|
| 109 | + | | `FRESHRSS_PASSWORD` | FreshRSS password | For FreshRSS mode | |
|
| 110 | + | | `ADMIN_PASSWORD` | Password for the admin panel | For admin access | |
|
| 111 | + | | `COOKIE_SECURE` | Set to `true` for HTTPS environments | No | |
|
| 112 | + | ||
| 113 | + | ## Deployment |
|
| 114 | + | ||
| 115 | + | Since Feeds compiles to a single binary, deployment is straightforward on any platform. |
|
| 116 | + | ||
| 117 | + | ### Self Hosting |
|
| 118 | + | ||
| 119 | + | If you are running a VPS or your own hardware like a Raspberry Pi, you can use a basic `systemd` service to manage the instance. |
|
| 120 | + | ||
| 121 | + | 1. Clone the repo and build |
|
| 122 | + | ||
| 123 | + | ```bash |
|
| 124 | + | git clone https://github.com/stevedylandev/feeds |
|
| 125 | + | cd feeds |
|
| 126 | + | cargo build --release |
|
| 127 | + | ``` |
|
| 128 | + | ||
| 129 | + | 2. Create a systemd service |
|
| 130 | + | ||
| 131 | + | The location of where these files are located might depend on your linux distribution, but most commonly they can be found at `/etc/systemd/system`. Create a new file called `feeds.service` and edit it with `nano` or `vim`. |
|
| 132 | + | ||
| 133 | + | ```bash |
|
| 134 | + | cd /etc/systemd/system |
|
| 135 | + | touch feeds.service |
|
| 136 | + | sudo nano feeds.service |
|
| 137 | + | ``` |
|
| 138 | + | ||
| 139 | + | Paste in the following code: |
|
| 140 | + | ||
| 141 | + | ```bash |
|
| 142 | + | [Unit] |
|
| 143 | + | # describe the app |
|
| 144 | + | Description=Feeds |
|
| 145 | + | # start the app after the network is available |
|
| 146 | + | After=network.target |
|
| 147 | + | ||
| 148 | + | [Service] |
|
| 149 | + | # usually you'll use 'simple' |
|
| 150 | + | # one of https://www.freedesktop.org/software/systemd/man/systemd.service.html#Type= |
|
| 151 | + | Type=simple |
|
| 152 | + | # which user to use when starting the app |
|
| 153 | + | User=YOUR_USER |
|
| 154 | + | # path to your application's root directory |
|
| 155 | + | WorkingDirectory=/home/YOUR_USER/feeds |
|
| 156 | + | # the command to start the app |
|
| 157 | + | ExecStart=/home/YOUR_USER/feeds/target/release/feeds |
|
| 158 | + | # restart policy |
|
| 159 | + | Restart=always |
|
| 160 | + | ||
| 161 | + | [Install] |
|
| 162 | + | # start the app automatically |
|
| 163 | + | WantedBy=multi-user.target |
|
| 164 | + | ``` |
|
| 165 | + | ||
| 166 | + | > [!NOTE] |
|
| 167 | + | > Make sure you update `YOUR_USER` with your own user info, and make sure the paths are correct! |
|
| 168 | + | ||
| 169 | + | 3. Start up the service |
|
| 170 | + | ||
| 171 | + | Run the following commands to enable and start the service |
|
| 172 | + | ||
| 173 | + | ```bash |
|
| 174 | + | sudo systemctl enable feeds.service |
|
| 175 | + | sudo systemctl start feeds |
|
| 176 | + | ``` |
|
| 177 | + | ||
| 178 | + | Check and make sure it's working |
|
| 179 | + | ||
| 180 | + | ```bash |
|
| 181 | + | sudo systemctl status feeds |
|
| 182 | + | ``` |
|
| 183 | + | ||
| 184 | + | 4. Setup a Tunnel (optional) |
|
| 185 | + | ||
| 186 | + | From here you have a lot of options of how you may want to access the instance. One easy way to start is to use a Cloudflare tunnel and point it to `http://localhost:4555`. |
|
| 187 | + | ||
| 188 | + | ||
| 189 | + | ### Docker |
|
| 190 | + | ||
| 191 | + | 1. Clone the repo |
|
| 192 | + | ||
| 193 | + | ```bash |
|
| 194 | + | git clone https://github.com/stevedylandev/feeds |
|
| 195 | + | cd feeds |
|
| 196 | + | ``` |
|
| 197 | + | ||
| 198 | + | 2. Build and run the Docker image |
|
| 199 | + | ||
| 200 | + | ```bash |
|
| 201 | + | docker build -t feeds . |
|
| 202 | + | docker run -p 4555:4555 --env-file .env feeds |
|
| 203 | + | ``` |
|
| 204 | + | ||
| 205 | + | Or use `docker-compose` |
|
| 206 | + | ||
| 207 | + | ```bash |
|
| 208 | + | docker-compose up -d |
|
| 209 | + | ``` |
|
| 210 | + | ||
| 211 | + | ### Railway |
|
| 212 | + | ||
| 213 | + | 1. Fork the repo from GitHub to your own account |
|
| 214 | + | ||
| 215 | + | 2. Login to [Railway](https://railway.com) and create a new project |
|
| 216 | + | ||
| 217 | + | 3. Select Feeds from your repos |
|
| 218 | + | ||
| 219 | + | 4. Railway will auto-detect the Rust project and build it |
| 1 | + | [general] |
|
| 2 | + | dirs = ["src/templates"] |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | + | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | + | * { |
|
| 2 | + | padding: 0; |
|
| 3 | + | margin: 0; |
|
| 4 | + | box-sizing: border-box; |
|
| 5 | + | font-family: "Commit Mono", monospace, sans-serif; |
|
| 6 | + | scrollbar-width: none; |
|
| 7 | + | -ms-overflow-style: none; |
|
| 8 | + | } |
|
| 9 | + | ||
| 10 | + | html { |
|
| 11 | + | background: #121113; |
|
| 12 | + | color: #ffffff; |
|
| 13 | + | } |
|
| 14 | + | ||
| 15 | + | html::-webkit-scrollbar { |
|
| 16 | + | display: none; |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | body { |
|
| 20 | + | display: flex; |
|
| 21 | + | flex-direction: column; |
|
| 22 | + | justify-content: start; |
|
| 23 | + | align-items: start; |
|
| 24 | + | gap: 1.5rem; |
|
| 25 | + | min-height: 100vh; |
|
| 26 | + | max-width: 700px; |
|
| 27 | + | margin: auto; |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | .header { |
|
| 31 | + | display: flex; |
|
| 32 | + | flex-direction: column; |
|
| 33 | + | gap: 0.5rem; |
|
| 34 | + | text-decoration: none; |
|
| 35 | + | margin-top: 2rem; |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | .links { |
|
| 39 | + | display: flex; |
|
| 40 | + | align-items: center; |
|
| 41 | + | gap: 0.75rem; |
|
| 42 | + | font-size: 12px; |
|
| 43 | + | } |
|
| 44 | + | ||
| 45 | + | .about { |
|
| 46 | + | display: flex; |
|
| 47 | + | flex-direction: column; |
|
| 48 | + | gap: 0.5rem; |
|
| 49 | + | font-size: 14px; |
|
| 50 | + | line-height: 1.25rem; |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | button { |
|
| 54 | + | background: #121113; |
|
| 55 | + | color: #ffffff; |
|
| 56 | + | padding: 6px; |
|
| 57 | + | border: 1px solid white; |
|
| 58 | + | cursor: pointer; |
|
| 59 | + | width: fit-content; |
|
| 60 | + | } |
|
| 61 | + | ||
| 62 | + | a { |
|
| 63 | + | background: #121113; |
|
| 64 | + | color: #ffffff; |
|
| 65 | + | } |
|
| 66 | + | ||
| 67 | + | ul { |
|
| 68 | + | margin-left: 1.5rem; |
|
| 69 | + | } |
|
| 70 | + | ||
| 71 | + | li { |
|
| 72 | + | padding: 0.5rem 0; |
|
| 73 | + | } |
|
| 74 | + | ||
| 75 | + | code { |
|
| 76 | + | background: #333; |
|
| 77 | + | padding: 3px; |
|
| 78 | + | } |
|
| 79 | + | ||
| 80 | + | .feeds-list { |
|
| 81 | + | width: 100%; |
|
| 82 | + | display: flex; |
|
| 83 | + | flex-direction: column; |
|
| 84 | + | gap: 1.5rem; |
|
| 85 | + | } |
|
| 86 | + | ||
| 87 | + | .feed-item { |
|
| 88 | + | display: flex; |
|
| 89 | + | flex-direction: column; |
|
| 90 | + | gap: 0.5rem; |
|
| 91 | + | padding: 1rem 0; |
|
| 92 | + | border-bottom: 1px solid #333; |
|
| 93 | + | } |
|
| 94 | + | ||
| 95 | + | #feed-urls { |
|
| 96 | + | font-size: 12px; |
|
| 97 | + | color: #888; |
|
| 98 | + | } |
|
| 99 | + | ||
| 100 | + | .feed-item:last-child { |
|
| 101 | + | border-bottom: none; |
|
| 102 | + | } |
|
| 103 | + | ||
| 104 | + | .feed-meta { |
|
| 105 | + | display: flex; |
|
| 106 | + | justify-content: space-between; |
|
| 107 | + | align-items: center; |
|
| 108 | + | font-size: 12px; |
|
| 109 | + | color: #888; |
|
| 110 | + | } |
|
| 111 | + | ||
| 112 | + | .feed-source { |
|
| 113 | + | font-weight: 700; |
|
| 114 | + | } |
|
| 115 | + | ||
| 116 | + | .feed-date { |
|
| 117 | + | color: #666; |
|
| 118 | + | } |
|
| 119 | + | ||
| 120 | + | .feed-title { |
|
| 121 | + | font-size: 16px; |
|
| 122 | + | font-weight: 400; |
|
| 123 | + | line-height: 1.4; |
|
| 124 | + | } |
|
| 125 | + | ||
| 126 | + | .feed-title a { |
|
| 127 | + | text-decoration: none; |
|
| 128 | + | color: #ffffff; |
|
| 129 | + | transition: color 0.2s ease; |
|
| 130 | + | } |
|
| 131 | + | ||
| 132 | + | .feed-title a:hover { |
|
| 133 | + | color: #ccc; |
|
| 134 | + | } |
|
| 135 | + | ||
| 136 | + | .feed-author { |
|
| 137 | + | font-size: 12px; |
|
| 138 | + | color: #888; |
|
| 139 | + | font-style: italic; |
|
| 140 | + | } |
|
| 141 | + | ||
| 142 | + | .no-feeds { |
|
| 143 | + | text-align: center; |
|
| 144 | + | color: #888; |
|
| 145 | + | padding: 2rem; |
|
| 146 | + | } |
|
| 147 | + | ||
| 148 | + | #loading { |
|
| 149 | + | text-align: center; |
|
| 150 | + | color: #888; |
|
| 151 | + | padding: 2rem; |
|
| 152 | + | } |
|
| 153 | + | ||
| 154 | + | #error { |
|
| 155 | + | text-align: center; |
|
| 156 | + | padding: 2rem; |
|
| 157 | + | } |
|
| 158 | + | ||
| 159 | + | @media (max-width: 480px) { |
|
| 160 | + | .feed-meta { |
|
| 161 | + | flex-direction: column; |
|
| 162 | + | align-items: flex-start; |
|
| 163 | + | gap: 0.25rem; |
|
| 164 | + | } |
|
| 165 | + | ||
| 166 | + | .feed-title { |
|
| 167 | + | font-size: 14px; |
|
| 168 | + | } |
|
| 169 | + | } |
|
| 170 | + | ||
| 171 | + | @media (max-width: 480px) { |
|
| 172 | + | body { |
|
| 173 | + | padding: 1rem; |
|
| 174 | + | gap: 1rem; |
|
| 175 | + | } |
|
| 176 | + | } |
|
| 177 | + | ||
| 178 | + | .admin-form { |
|
| 179 | + | display: flex; |
|
| 180 | + | flex-direction: column; |
|
| 181 | + | gap: 0.75rem; |
|
| 182 | + | width: 100%; |
|
| 183 | + | } |
|
| 184 | + | ||
| 185 | + | .admin-form label { |
|
| 186 | + | font-size: 12px; |
|
| 187 | + | color: #888; |
|
| 188 | + | text-transform: uppercase; |
|
| 189 | + | letter-spacing: 0.05em; |
|
| 190 | + | } |
|
| 191 | + | ||
| 192 | + | .admin-form input { |
|
| 193 | + | background: #1a1a1c; |
|
| 194 | + | color: #ffffff; |
|
| 195 | + | border: 1px solid #333; |
|
| 196 | + | padding: 10px; |
|
| 197 | + | font-family: "Commit Mono", monospace, sans-serif; |
|
| 198 | + | font-size: 14px; |
|
| 199 | + | outline: none; |
|
| 200 | + | } |
|
| 201 | + | ||
| 202 | + | .admin-form input:focus { |
|
| 203 | + | border-color: #666; |
|
| 204 | + | } |
|
| 205 | + | ||
| 206 | + | .error-msg { |
|
| 207 | + | color: #ff6b6b; |
|
| 208 | + | font-size: 14px; |
|
| 209 | + | } |
|
| 210 | + | ||
| 211 | + | .success-msg { |
|
| 212 | + | color: #6bff8a; |
|
| 213 | + | font-size: 14px; |
|
| 214 | + | } |
|
| 215 | + | ||
| 216 | + | .admin-notice { |
|
| 217 | + | font-size: 14px; |
|
| 218 | + | color: #888; |
|
| 219 | + | line-height: 1.4; |
|
| 220 | + | } |
|
| 221 | + | ||
| 222 | + | .admin-subs { |
|
| 223 | + | width: 100%; |
|
| 224 | + | display: flex; |
|
| 225 | + | flex-direction: column; |
|
| 226 | + | gap: 1rem; |
|
| 227 | + | } |
|
| 228 | + | ||
| 229 | + | .admin-subs h3 { |
|
| 230 | + | font-size: 14px; |
|
| 231 | + | color: #888; |
|
| 232 | + | font-weight: 400; |
|
| 233 | + | } |
|
| 234 | + | ||
| 235 | + | @font-face { |
|
| 236 | + | font-family: "Commit Mono"; |
|
| 237 | + | src: url("fonts/CommitMono-400-Regular.otf") format("opentype"); |
|
| 238 | + | font-weight: 400; |
|
| 239 | + | font-style: normal; |
|
| 240 | + | } |
|
| 241 | + | ||
| 242 | + | @font-face { |
|
| 243 | + | font-family: "Commit Mono"; |
|
| 244 | + | src: url("fonts/CommitMono-700-Regular.otf") format("opentype"); |
|
| 245 | + | font-weight: 700; |
|
| 246 | + | font-style: normal; |
|
| 247 | + | } |
| 1 | + | services: |
|
| 2 | + | feeds: |
|
| 3 | + | build: |
|
| 4 | + | context: ../.. |
|
| 5 | + | dockerfile: apps/feeds/Dockerfile |
|
| 6 | + | ports: |
|
| 7 | + | - "4555:4555" |
|
| 8 | + | volumes: |
|
| 9 | + | - feeds_data:/app/data |
|
| 10 | + | env_file: |
|
| 11 | + | - .env |
|
| 12 | + | restart: unless-stopped |
|
| 13 | + | ||
| 14 | + | volumes: |
|
| 15 | + | feeds_data: |
| 1 | + | use axum::{ |
|
| 2 | + | extract::{FromRef, FromRequestParts}, |
|
| 3 | + | http::request::Parts, |
|
| 4 | + | response::{IntoResponse, Redirect, Response}, |
|
| 5 | + | }; |
|
| 6 | + | use std::collections::HashMap; |
|
| 7 | + | use std::sync::{Arc, Mutex}; |
|
| 8 | + | use std::time::{Duration, Instant}; |
|
| 9 | + | ||
| 10 | + | use crate::AppState; |
|
| 11 | + | ||
| 12 | + | pub use andromeda_auth::{ |
|
| 13 | + | build_session_cookie, clear_session_cookie, extract_session_cookie, generate_session_token, |
|
| 14 | + | verify_password, |
|
| 15 | + | }; |
|
| 16 | + | ||
| 17 | + | pub type SessionStore = Arc<Mutex<HashMap<String, Instant>>>; |
|
| 18 | + | ||
| 19 | + | const SESSION_TTL: Duration = Duration::from_secs(7 * 24 * 60 * 60); // 7 days |
|
| 20 | + | ||
| 21 | + | pub fn new_session_store() -> SessionStore { |
|
| 22 | + | Arc::new(Mutex::new(HashMap::new())) |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | pub fn create_session(store: &SessionStore, token: &str) { |
|
| 26 | + | if let Ok(mut sessions) = store.lock() { |
|
| 27 | + | sessions.insert(token.to_string(), Instant::now()); |
|
| 28 | + | } |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | pub fn is_valid_session(store: &SessionStore, token: &str) -> bool { |
|
| 32 | + | if let Ok(mut sessions) = store.lock() { |
|
| 33 | + | if let Some(created) = sessions.get(token) { |
|
| 34 | + | if created.elapsed() < SESSION_TTL { |
|
| 35 | + | return true; |
|
| 36 | + | } |
|
| 37 | + | sessions.remove(token); |
|
| 38 | + | } |
|
| 39 | + | } |
|
| 40 | + | false |
|
| 41 | + | } |
|
| 42 | + | ||
| 43 | + | pub fn delete_session(store: &SessionStore, token: &str) { |
|
| 44 | + | if let Ok(mut sessions) = store.lock() { |
|
| 45 | + | sessions.remove(token); |
|
| 46 | + | } |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | /// Axum extractor — guards routes behind login. Redirects to /admin/login if invalid. |
|
| 50 | + | pub struct AuthSession; |
|
| 51 | + | ||
| 52 | + | impl<S> FromRequestParts<S> for AuthSession |
|
| 53 | + | where |
|
| 54 | + | S: Send + Sync, |
|
| 55 | + | Arc<AppState>: FromRef<S>, |
|
| 56 | + | { |
|
| 57 | + | type Rejection = Response; |
|
| 58 | + | ||
| 59 | + | async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { |
|
| 60 | + | let state = Arc::<AppState>::from_ref(state); |
|
| 61 | + | let token = extract_session_cookie(&parts.headers); |
|
| 62 | + | if let Some(token) = token { |
|
| 63 | + | if is_valid_session(&state.sessions, &token) { |
|
| 64 | + | return Ok(AuthSession); |
|
| 65 | + | } |
|
| 66 | + | } |
|
| 67 | + | Err(Redirect::to("/admin/login").into_response()) |
|
| 68 | + | } |
|
| 69 | + | } |
| 1 | + | use crate::models::{FeedItem, FreshRSSResponse, SubscriptionList}; |
|
| 2 | + | use std::time::Duration; |
|
| 3 | + | ||
| 4 | + | fn build_client() -> reqwest::Client { |
|
| 5 | + | reqwest::Client::builder() |
|
| 6 | + | .timeout(Duration::from_secs(5)) |
|
| 7 | + | .build() |
|
| 8 | + | .expect("Failed to build HTTP client") |
|
| 9 | + | } |
|
| 10 | + | ||
| 11 | + | async fn fetch_feed_from_url(client: &reqwest::Client, url: &str) -> Vec<FeedItem> { |
|
| 12 | + | let response = match client.get(url).send().await { |
|
| 13 | + | Ok(r) => r, |
|
| 14 | + | Err(e) => { |
|
| 15 | + | eprintln!("Failed to fetch feed {url}: {e}"); |
|
| 16 | + | return Vec::new(); |
|
| 17 | + | } |
|
| 18 | + | }; |
|
| 19 | + | ||
| 20 | + | let body = match response.bytes().await { |
|
| 21 | + | Ok(b) => b, |
|
| 22 | + | Err(e) => { |
|
| 23 | + | eprintln!("Failed to read feed body {url}: {e}"); |
|
| 24 | + | return Vec::new(); |
|
| 25 | + | } |
|
| 26 | + | }; |
|
| 27 | + | ||
| 28 | + | let feed = match feed_rs::parser::parse(&body[..]) { |
|
| 29 | + | Ok(f) => f, |
|
| 30 | + | Err(e) => { |
|
| 31 | + | eprintln!("Failed to parse feed {url}: {e}"); |
|
| 32 | + | return Vec::new(); |
|
| 33 | + | } |
|
| 34 | + | }; |
|
| 35 | + | ||
| 36 | + | let feed_title = feed |
|
| 37 | + | .title |
|
| 38 | + | .as_ref() |
|
| 39 | + | .map(|t| t.content.clone()) |
|
| 40 | + | .unwrap_or_default(); |
|
| 41 | + | ||
| 42 | + | feed.entries |
|
| 43 | + | .iter() |
|
| 44 | + | .map(|entry| { |
|
| 45 | + | let published = entry |
|
| 46 | + | .published |
|
| 47 | + | .or(entry.updated) |
|
| 48 | + | .map(|dt| dt.timestamp()) |
|
| 49 | + | .unwrap_or(0); |
|
| 50 | + | ||
| 51 | + | let link = entry |
|
| 52 | + | .links |
|
| 53 | + | .first() |
|
| 54 | + | .map(|l| l.href.clone()) |
|
| 55 | + | .unwrap_or_default(); |
|
| 56 | + | ||
| 57 | + | let title = entry |
|
| 58 | + | .title |
|
| 59 | + | .as_ref() |
|
| 60 | + | .map(|t| t.content.clone()) |
|
| 61 | + | .unwrap_or_default(); |
|
| 62 | + | ||
| 63 | + | let id = entry.id.clone(); |
|
| 64 | + | ||
| 65 | + | let entry_author = entry |
|
| 66 | + | .authors |
|
| 67 | + | .first() |
|
| 68 | + | .map(|a| a.name.clone()) |
|
| 69 | + | .unwrap_or_default(); |
|
| 70 | + | ||
| 71 | + | let author = if entry_author.is_empty() { |
|
| 72 | + | feed_title.clone() |
|
| 73 | + | } else { |
|
| 74 | + | format!("{} - {}", feed_title, entry_author) |
|
| 75 | + | }; |
|
| 76 | + | ||
| 77 | + | FeedItem { |
|
| 78 | + | id, |
|
| 79 | + | title, |
|
| 80 | + | published, |
|
| 81 | + | author, |
|
| 82 | + | link, |
|
| 83 | + | origin: feed_title.clone(), |
|
| 84 | + | } |
|
| 85 | + | }) |
|
| 86 | + | .collect() |
|
| 87 | + | } |
|
| 88 | + | ||
| 89 | + | pub async fn parse_urls(urls: &[String]) -> Vec<FeedItem> { |
|
| 90 | + | let client = build_client(); |
|
| 91 | + | let mut handles = Vec::new(); |
|
| 92 | + | ||
| 93 | + | for url in urls { |
|
| 94 | + | let client = client.clone(); |
|
| 95 | + | let url = url.clone(); |
|
| 96 | + | handles.push(tokio::spawn(async move { |
|
| 97 | + | fetch_feed_from_url(&client, &url).await |
|
| 98 | + | })); |
|
| 99 | + | } |
|
| 100 | + | ||
| 101 | + | let mut all_items = Vec::new(); |
|
| 102 | + | for handle in handles { |
|
| 103 | + | if let Ok(items) = handle.await { |
|
| 104 | + | all_items.extend(items); |
|
| 105 | + | } |
|
| 106 | + | } |
|
| 107 | + | ||
| 108 | + | all_items.sort_by(|a, b| b.published.cmp(&a.published)); |
|
| 109 | + | all_items |
|
| 110 | + | } |
|
| 111 | + | ||
| 112 | + | pub fn parse_opml(content: &str) -> Vec<String> { |
|
| 113 | + | let mut urls = Vec::new(); |
|
| 114 | + | let mut reader = quick_xml::Reader::from_str(content); |
|
| 115 | + | ||
| 116 | + | loop { |
|
| 117 | + | match reader.read_event() { |
|
| 118 | + | Ok(quick_xml::events::Event::Empty(ref e)) |
|
| 119 | + | | Ok(quick_xml::events::Event::Start(ref e)) => { |
|
| 120 | + | if e.name().as_ref() == b"outline" { |
|
| 121 | + | for attr in e.attributes().flatten() { |
|
| 122 | + | if attr.key.as_ref() == b"xmlUrl" { |
|
| 123 | + | if let Ok(val) = attr.decode_and_unescape_value(reader.decoder()) { |
|
| 124 | + | let url = val.to_string(); |
|
| 125 | + | if !url.is_empty() { |
|
| 126 | + | urls.push(url); |
|
| 127 | + | } |
|
| 128 | + | } |
|
| 129 | + | } |
|
| 130 | + | } |
|
| 131 | + | } |
|
| 132 | + | } |
|
| 133 | + | Ok(quick_xml::events::Event::Eof) => break, |
|
| 134 | + | Err(e) => { |
|
| 135 | + | eprintln!("Error parsing OPML: {e}"); |
|
| 136 | + | break; |
|
| 137 | + | } |
|
| 138 | + | _ => {} |
|
| 139 | + | } |
|
| 140 | + | } |
|
| 141 | + | ||
| 142 | + | urls |
|
| 143 | + | } |
|
| 144 | + | ||
| 145 | + | async fn freshrss_auth( |
|
| 146 | + | client: &reqwest::Client, |
|
| 147 | + | freshrss_url: &str, |
|
| 148 | + | username: &str, |
|
| 149 | + | password: &str, |
|
| 150 | + | ) -> Result<String, String> { |
|
| 151 | + | let auth_url = format!( |
|
| 152 | + | "{}/api/greader.php/accounts/ClientLogin?Email={}&Passwd={}", |
|
| 153 | + | freshrss_url, username, password |
|
| 154 | + | ); |
|
| 155 | + | ||
| 156 | + | let response = client |
|
| 157 | + | .get(&auth_url) |
|
| 158 | + | .send() |
|
| 159 | + | .await |
|
| 160 | + | .map_err(|e| format!("Auth request failed: {e}"))?; |
|
| 161 | + | ||
| 162 | + | let text = response |
|
| 163 | + | .text() |
|
| 164 | + | .await |
|
| 165 | + | .map_err(|e| format!("Failed to read auth response: {e}"))?; |
|
| 166 | + | ||
| 167 | + | for line in text.lines() { |
|
| 168 | + | if let Some(token) = line.strip_prefix("Auth=") { |
|
| 169 | + | return Ok(token.trim().to_string()); |
|
| 170 | + | } |
|
| 171 | + | } |
|
| 172 | + | ||
| 173 | + | Err("Authentication failed: no Auth token found".to_string()) |
|
| 174 | + | } |
|
| 175 | + | ||
| 176 | + | pub async fn fetch_freshrss_items( |
|
| 177 | + | freshrss_url: &str, |
|
| 178 | + | username: &str, |
|
| 179 | + | password: &str, |
|
| 180 | + | ) -> Result<Vec<FeedItem>, String> { |
|
| 181 | + | let client = build_client(); |
|
| 182 | + | let token = freshrss_auth(&client, freshrss_url, username, password).await?; |
|
| 183 | + | ||
| 184 | + | let url = format!( |
|
| 185 | + | "{}/api/greader.php/reader/api/0/stream/contents/reading-list?n=60&r=d", |
|
| 186 | + | freshrss_url |
|
| 187 | + | ); |
|
| 188 | + | ||
| 189 | + | let response = client |
|
| 190 | + | .get(&url) |
|
| 191 | + | .header("Authorization", format!("GoogleLogin auth={token}")) |
|
| 192 | + | .send() |
|
| 193 | + | .await |
|
| 194 | + | .map_err(|e| format!("Failed to fetch reading list: {e}"))?; |
|
| 195 | + | ||
| 196 | + | let data: FreshRSSResponse = response |
|
| 197 | + | .json() |
|
| 198 | + | .await |
|
| 199 | + | .map_err(|e| format!("Failed to parse FreshRSS response: {e}"))?; |
|
| 200 | + | ||
| 201 | + | let mut items: Vec<FeedItem> = data |
|
| 202 | + | .items |
|
| 203 | + | .iter() |
|
| 204 | + | .map(|item| { |
|
| 205 | + | let link = item |
|
| 206 | + | .canonical |
|
| 207 | + | .as_ref() |
|
| 208 | + | .and_then(|c| c.first()) |
|
| 209 | + | .map(|l| l.href.clone()) |
|
| 210 | + | .unwrap_or_default(); |
|
| 211 | + | ||
| 212 | + | FeedItem { |
|
| 213 | + | id: item.id.clone(), |
|
| 214 | + | title: item.title.clone(), |
|
| 215 | + | published: item.published, |
|
| 216 | + | author: item.origin.title.clone(), |
|
| 217 | + | link, |
|
| 218 | + | origin: item.origin.title.clone(), |
|
| 219 | + | } |
|
| 220 | + | }) |
|
| 221 | + | .collect(); |
|
| 222 | + | ||
| 223 | + | items.sort_by(|a, b| b.published.cmp(&a.published)); |
|
| 224 | + | Ok(items) |
|
| 225 | + | } |
|
| 226 | + | ||
| 227 | + | pub async fn fetch_freshrss_subscriptions( |
|
| 228 | + | freshrss_url: &str, |
|
| 229 | + | username: &str, |
|
| 230 | + | password: &str, |
|
| 231 | + | ) -> Result<SubscriptionList, String> { |
|
| 232 | + | let client = build_client(); |
|
| 233 | + | let token = freshrss_auth(&client, freshrss_url, username, password).await?; |
|
| 234 | + | ||
| 235 | + | let url = format!( |
|
| 236 | + | "{}/api/greader.php/reader/api/0/subscription/list?output=json", |
|
| 237 | + | freshrss_url |
|
| 238 | + | ); |
|
| 239 | + | ||
| 240 | + | let response = client |
|
| 241 | + | .get(&url) |
|
| 242 | + | .header("Authorization", format!("GoogleLogin auth={token}")) |
|
| 243 | + | .send() |
|
| 244 | + | .await |
|
| 245 | + | .map_err(|e| format!("Failed to fetch subscriptions: {e}"))?; |
|
| 246 | + | ||
| 247 | + | if !response.status().is_success() { |
|
| 248 | + | return Err(format!("FreshRSS API error: {}", response.status())); |
|
| 249 | + | } |
|
| 250 | + | ||
| 251 | + | let data: SubscriptionList = response |
|
| 252 | + | .json() |
|
| 253 | + | .await |
|
| 254 | + | .map_err(|e| format!("Failed to parse subscription list: {e}"))?; |
|
| 255 | + | ||
| 256 | + | Ok(data) |
|
| 257 | + | } |
|
| 258 | + | ||
| 259 | + | pub async fn add_freshrss_subscription( |
|
| 260 | + | freshrss_url: &str, |
|
| 261 | + | username: &str, |
|
| 262 | + | password: &str, |
|
| 263 | + | feed_url: &str, |
|
| 264 | + | ) -> Result<String, String> { |
|
| 265 | + | let client = build_client(); |
|
| 266 | + | let token = freshrss_auth(&client, freshrss_url, username, password).await?; |
|
| 267 | + | ||
| 268 | + | let url = format!( |
|
| 269 | + | "{}/api/greader.php/reader/api/0/subscription/quickadd", |
|
| 270 | + | freshrss_url |
|
| 271 | + | ); |
|
| 272 | + | ||
| 273 | + | let response = client |
|
| 274 | + | .post(&url) |
|
| 275 | + | .header("Authorization", format!("GoogleLogin auth={token}")) |
|
| 276 | + | .form(&[("quickadd", feed_url)]) |
|
| 277 | + | .send() |
|
| 278 | + | .await |
|
| 279 | + | .map_err(|e| format!("Failed to add subscription: {e}"))?; |
|
| 280 | + | ||
| 281 | + | if !response.status().is_success() { |
|
| 282 | + | let status = response.status(); |
|
| 283 | + | let body = response.text().await.unwrap_or_default(); |
|
| 284 | + | return Err(format!("FreshRSS API error ({}): {}", status, body)); |
|
| 285 | + | } |
|
| 286 | + | ||
| 287 | + | // Assign the "Feeds" category via subscription/edit |
|
| 288 | + | let edit_url = format!( |
|
| 289 | + | "{}/api/greader.php/reader/api/0/subscription/edit", |
|
| 290 | + | freshrss_url |
|
| 291 | + | ); |
|
| 292 | + | ||
| 293 | + | let stream_id = format!("feed/{feed_url}"); |
|
| 294 | + | let response = client |
|
| 295 | + | .post(&edit_url) |
|
| 296 | + | .header("Authorization", format!("GoogleLogin auth={token}")) |
|
| 297 | + | .form(&[ |
|
| 298 | + | ("ac", "edit"), |
|
| 299 | + | ("s", &stream_id), |
|
| 300 | + | ("a", "user/-/label/Feeds"), |
|
| 301 | + | ]) |
|
| 302 | + | .send() |
|
| 303 | + | .await |
|
| 304 | + | .map_err(|e| format!("Feed added but failed to set category: {e}"))?; |
|
| 305 | + | ||
| 306 | + | if !response.status().is_success() { |
|
| 307 | + | let status = response.status(); |
|
| 308 | + | let body = response.text().await.unwrap_or_default(); |
|
| 309 | + | return Err(format!( |
|
| 310 | + | "Feed added but failed to set category ({}): {}", |
|
| 311 | + | status, body |
|
| 312 | + | )); |
|
| 313 | + | } |
|
| 314 | + | ||
| 315 | + | Ok(format!("Successfully added feed: {feed_url}")) |
|
| 316 | + | } |
|
| 317 | + | ||
| 318 | + | pub async fn get_feed_items( |
|
| 319 | + | url_query: Option<&str>, |
|
| 320 | + | ) -> Result<(Vec<FeedItem>, Option<Vec<String>>), String> { |
|
| 321 | + | // Priority 1: URL query parameter |
|
| 322 | + | if let Some(query) = url_query { |
|
| 323 | + | let urls: Vec<String> = query |
|
| 324 | + | .split(',') |
|
| 325 | + | .map(|u| u.trim().to_string()) |
|
| 326 | + | .filter(|u| !u.is_empty()) |
|
| 327 | + | .collect(); |
|
| 328 | + | ||
| 329 | + | if !urls.is_empty() { |
|
| 330 | + | let items = parse_urls(&urls).await; |
|
| 331 | + | return Ok((items, Some(urls))); |
|
| 332 | + | } |
|
| 333 | + | } |
|
| 334 | + | ||
| 335 | + | // Priority 2: Local OPML file |
|
| 336 | + | if let Ok(content) = tokio::fs::read_to_string("feeds.opml").await { |
|
| 337 | + | let urls = parse_opml(&content); |
|
| 338 | + | if !urls.is_empty() { |
|
| 339 | + | let items = parse_urls(&urls).await; |
|
| 340 | + | return Ok((items, None)); |
|
| 341 | + | } |
|
| 342 | + | } |
|
| 343 | + | ||
| 344 | + | // Priority 3: FreshRSS fallback |
|
| 345 | + | let freshrss_url = std::env::var("FRESHRSS_URL").map_err(|_| "FRESHRSS_URL not set")?; |
|
| 346 | + | let username = |
|
| 347 | + | std::env::var("FRESHRSS_USERNAME").map_err(|_| "FRESHRSS_USERNAME not set")?; |
|
| 348 | + | let password = |
|
| 349 | + | std::env::var("FRESHRSS_PASSWORD").map_err(|_| "FRESHRSS_PASSWORD not set")?; |
|
| 350 | + | ||
| 351 | + | let items = fetch_freshrss_items(&freshrss_url, &username, &password).await?; |
|
| 352 | + | Ok((items, None)) |
|
| 353 | + | } |
| 1 | + | mod auth; |
|
| 2 | + | mod feeds; |
|
| 3 | + | mod models; |
|
| 4 | + | ||
| 5 | + | use askama::Template; |
|
| 6 | + | use axum::{ |
|
| 7 | + | extract::{Query, State}, |
|
| 8 | + | http::{header, HeaderMap, StatusCode}, |
|
| 9 | + | response::{Html, IntoResponse, Json, Redirect, Response}, |
|
| 10 | + | routing::{get, post}, |
|
| 11 | + | Form, Router, |
|
| 12 | + | }; |
|
| 13 | + | use chrono::DateTime; |
|
| 14 | + | use rust_embed::Embed; |
|
| 15 | + | use serde::Deserialize; |
|
| 16 | + | use std::collections::HashMap; |
|
| 17 | + | use std::sync::Arc; |
|
| 18 | + | ||
| 19 | + | #[derive(Embed)] |
|
| 20 | + | #[folder = "assets/"] |
|
| 21 | + | struct Assets; |
|
| 22 | + | ||
| 23 | + | pub struct AppState { |
|
| 24 | + | sessions: auth::SessionStore, |
|
| 25 | + | admin_password: Option<String>, |
|
| 26 | + | cookie_secure: bool, |
|
| 27 | + | } |
|
| 28 | + | ||
| 29 | + | struct TemplateFeedItem { |
|
| 30 | + | title: String, |
|
| 31 | + | link: String, |
|
| 32 | + | author: String, |
|
| 33 | + | formatted_date: String, |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | #[derive(Template)] |
|
| 37 | + | #[template(path = "index.html")] |
|
| 38 | + | struct IndexTemplate { |
|
| 39 | + | items: Vec<TemplateFeedItem>, |
|
| 40 | + | feed_urls: Option<Vec<String>>, |
|
| 41 | + | error: Option<String>, |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | #[derive(Template)] |
|
| 45 | + | #[template(path = "login.html")] |
|
| 46 | + | struct LoginTemplate { |
|
| 47 | + | error: Option<String>, |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | #[derive(Template)] |
|
| 51 | + | #[template(path = "admin.html")] |
|
| 52 | + | struct AdminTemplate { |
|
| 53 | + | freshrss_configured: bool, |
|
| 54 | + | success: Option<String>, |
|
| 55 | + | error: Option<String>, |
|
| 56 | + | subscriptions: Option<Vec<models::Subscription>>, |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | fn format_date(timestamp: i64) -> String { |
|
| 60 | + | DateTime::from_timestamp(timestamp, 0) |
|
| 61 | + | .map(|dt| dt.format("%b %-d, %Y").to_string()) |
|
| 62 | + | .unwrap_or_default() |
|
| 63 | + | } |
|
| 64 | + | ||
| 65 | + | fn freshrss_env() -> Option<(String, String, String)> { |
|
| 66 | + | let url = std::env::var("FRESHRSS_URL").ok()?; |
|
| 67 | + | let username = std::env::var("FRESHRSS_USERNAME").ok()?; |
|
| 68 | + | let password = std::env::var("FRESHRSS_PASSWORD").ok()?; |
|
| 69 | + | Some((url, username, password)) |
|
| 70 | + | } |
|
| 71 | + | ||
| 72 | + | async fn index_handler(Query(params): Query<HashMap<String, String>>) -> impl IntoResponse { |
|
| 73 | + | let url_query = params |
|
| 74 | + | .get("url") |
|
| 75 | + | .or_else(|| params.get("urls")) |
|
| 76 | + | .map(|s| s.as_str()); |
|
| 77 | + | ||
| 78 | + | let template = match feeds::get_feed_items(url_query).await { |
|
| 79 | + | Ok((items, feed_urls)) => { |
|
| 80 | + | let template_items: Vec<TemplateFeedItem> = items |
|
| 81 | + | .into_iter() |
|
| 82 | + | .map(|item| TemplateFeedItem { |
|
| 83 | + | title: item.title, |
|
| 84 | + | link: item.link, |
|
| 85 | + | author: item.author, |
|
| 86 | + | formatted_date: format_date(item.published), |
|
| 87 | + | }) |
|
| 88 | + | .collect(); |
|
| 89 | + | ||
| 90 | + | IndexTemplate { |
|
| 91 | + | items: template_items, |
|
| 92 | + | feed_urls, |
|
| 93 | + | error: None, |
|
| 94 | + | } |
|
| 95 | + | } |
|
| 96 | + | Err(e) => { |
|
| 97 | + | eprintln!("Error fetching feeds: {e}"); |
|
| 98 | + | IndexTemplate { |
|
| 99 | + | items: Vec::new(), |
|
| 100 | + | feed_urls: None, |
|
| 101 | + | error: Some("Error loading feeds. Please try again later.".to_string()), |
|
| 102 | + | } |
|
| 103 | + | } |
|
| 104 | + | }; |
|
| 105 | + | ||
| 106 | + | Html(template.render().unwrap()) |
|
| 107 | + | } |
|
| 108 | + | ||
| 109 | + | async fn feeds_handler( |
|
| 110 | + | Query(params): Query<HashMap<String, String>>, |
|
| 111 | + | ) -> Result<Response, StatusCode> { |
|
| 112 | + | let format = params |
|
| 113 | + | .get("format") |
|
| 114 | + | .map(|s| s.as_str()) |
|
| 115 | + | .unwrap_or("json"); |
|
| 116 | + | ||
| 117 | + | let freshrss_url = |
|
| 118 | + | std::env::var("FRESHRSS_URL").map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; |
|
| 119 | + | let username = |
|
| 120 | + | std::env::var("FRESHRSS_USERNAME").map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; |
|
| 121 | + | let password = |
|
| 122 | + | std::env::var("FRESHRSS_PASSWORD").map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; |
|
| 123 | + | ||
| 124 | + | let data = feeds::fetch_freshrss_subscriptions(&freshrss_url, &username, &password) |
|
| 125 | + | .await |
|
| 126 | + | .map_err(|e| { |
|
| 127 | + | eprintln!("Failed to fetch subscriptions: {e}"); |
|
| 128 | + | StatusCode::INTERNAL_SERVER_ERROR |
|
| 129 | + | })?; |
|
| 130 | + | ||
| 131 | + | match format { |
|
| 132 | + | "json" => Ok(Json(serde_json::json!(data)).into_response()), |
|
| 133 | + | "opml" => { |
|
| 134 | + | let now = chrono::Utc::now().to_rfc2822(); |
|
| 135 | + | let subscriptions = data.subscriptions.unwrap_or_default(); |
|
| 136 | + | ||
| 137 | + | let mut opml = format!( |
|
| 138 | + | r#"<?xml version="1.0" encoding="UTF-8"?> |
|
| 139 | + | <opml version="2.0"> |
|
| 140 | + | <head> |
|
| 141 | + | <title>Steve's Feeds</title> |
|
| 142 | + | <dateCreated>{now}</dateCreated> |
|
| 143 | + | </head> |
|
| 144 | + | <body> |
|
| 145 | + | "# |
|
| 146 | + | ); |
|
| 147 | + | ||
| 148 | + | for feed in &subscriptions { |
|
| 149 | + | opml.push_str(&format!( |
|
| 150 | + | " <outline type=\"rss\" text=\"{}\" title=\"{}\" xmlUrl=\"{}\" htmlUrl=\"{}\" />\n", |
|
| 151 | + | escape_xml(&feed.title), |
|
| 152 | + | escape_xml(&feed.title), |
|
| 153 | + | escape_xml(&feed.url), |
|
| 154 | + | escape_xml(feed.html_url.as_deref().unwrap_or("")), |
|
| 155 | + | )); |
|
| 156 | + | } |
|
| 157 | + | ||
| 158 | + | opml.push_str(" </body>\n</opml>"); |
|
| 159 | + | ||
| 160 | + | Ok(( |
|
| 161 | + | [ |
|
| 162 | + | (header::CONTENT_TYPE, "application/xml"), |
|
| 163 | + | ( |
|
| 164 | + | header::CONTENT_DISPOSITION, |
|
| 165 | + | "attachment; filename=\"feeds.opml\"", |
|
| 166 | + | ), |
|
| 167 | + | ], |
|
| 168 | + | opml, |
|
| 169 | + | ) |
|
| 170 | + | .into_response()) |
|
| 171 | + | } |
|
| 172 | + | _ => Ok(( |
|
| 173 | + | StatusCode::BAD_REQUEST, |
|
| 174 | + | Json(serde_json::json!({ |
|
| 175 | + | "error": "Invalid format. Use ?format=json or ?format=opml" |
|
| 176 | + | })), |
|
| 177 | + | ) |
|
| 178 | + | .into_response()), |
|
| 179 | + | } |
|
| 180 | + | } |
|
| 181 | + | ||
| 182 | + | fn escape_xml(s: &str) -> String { |
|
| 183 | + | s.replace('&', "&") |
|
| 184 | + | .replace('<', "<") |
|
| 185 | + | .replace('>', ">") |
|
| 186 | + | .replace('"', """) |
|
| 187 | + | .replace('\'', "'") |
|
| 188 | + | } |
|
| 189 | + | ||
| 190 | + | async fn static_handler(axum::extract::Path(path): axum::extract::Path<String>) -> Response { |
|
| 191 | + | match Assets::get(&path) { |
|
| 192 | + | Some(file) => { |
|
| 193 | + | let mime = mime_guess::from_path(&path).first_or_octet_stream(); |
|
| 194 | + | ( |
|
| 195 | + | [(header::CONTENT_TYPE, mime.as_ref())], |
|
| 196 | + | file.data.to_vec(), |
|
| 197 | + | ) |
|
| 198 | + | .into_response() |
|
| 199 | + | } |
|
| 200 | + | None => StatusCode::NOT_FOUND.into_response(), |
|
| 201 | + | } |
|
| 202 | + | } |
|
| 203 | + | ||
| 204 | + | // --- Admin routes --- |
|
| 205 | + | ||
| 206 | + | #[derive(Deserialize, Default)] |
|
| 207 | + | struct FlashQuery { |
|
| 208 | + | error: Option<String>, |
|
| 209 | + | success: Option<String>, |
|
| 210 | + | } |
|
| 211 | + | ||
| 212 | + | #[derive(Deserialize)] |
|
| 213 | + | struct LoginForm { |
|
| 214 | + | password: String, |
|
| 215 | + | } |
|
| 216 | + | ||
| 217 | + | #[derive(Deserialize)] |
|
| 218 | + | struct AddFeedForm { |
|
| 219 | + | feed_url: String, |
|
| 220 | + | } |
|
| 221 | + | ||
| 222 | + | async fn login_get_handler(Query(q): Query<FlashQuery>) -> impl IntoResponse { |
|
| 223 | + | Html(LoginTemplate { error: q.error }.render().unwrap()) |
|
| 224 | + | } |
|
| 225 | + | ||
| 226 | + | async fn login_post_handler( |
|
| 227 | + | State(state): State<Arc<AppState>>, |
|
| 228 | + | Form(form): Form<LoginForm>, |
|
| 229 | + | ) -> Response { |
|
| 230 | + | let admin_password = match &state.admin_password { |
|
| 231 | + | Some(p) => p, |
|
| 232 | + | None => { |
|
| 233 | + | return Redirect::to("/admin/login?error=No+admin+password+configured").into_response(); |
|
| 234 | + | } |
|
| 235 | + | }; |
|
| 236 | + | ||
| 237 | + | if !auth::verify_password(&form.password, admin_password) { |
|
| 238 | + | return Redirect::to("/admin/login?error=Invalid+password").into_response(); |
|
| 239 | + | } |
|
| 240 | + | ||
| 241 | + | let token = auth::generate_session_token(); |
|
| 242 | + | auth::create_session(&state.sessions, &token); |
|
| 243 | + | let cookie = auth::build_session_cookie(&token, state.cookie_secure); |
|
| 244 | + | ||
| 245 | + | let mut resp = Redirect::to("/admin").into_response(); |
|
| 246 | + | resp.headers_mut() |
|
| 247 | + | .insert(header::SET_COOKIE, cookie.parse().unwrap()); |
|
| 248 | + | resp |
|
| 249 | + | } |
|
| 250 | + | ||
| 251 | + | async fn logout_handler( |
|
| 252 | + | State(state): State<Arc<AppState>>, |
|
| 253 | + | headers: HeaderMap, |
|
| 254 | + | ) -> Response { |
|
| 255 | + | if let Some(token) = auth::extract_session_cookie(&headers) { |
|
| 256 | + | auth::delete_session(&state.sessions, &token); |
|
| 257 | + | } |
|
| 258 | + | let mut resp = Redirect::to("/admin/login").into_response(); |
|
| 259 | + | resp.headers_mut().insert( |
|
| 260 | + | header::SET_COOKIE, |
|
| 261 | + | auth::clear_session_cookie().parse().unwrap(), |
|
| 262 | + | ); |
|
| 263 | + | resp |
|
| 264 | + | } |
|
| 265 | + | ||
| 266 | + | async fn admin_handler( |
|
| 267 | + | _session: auth::AuthSession, |
|
| 268 | + | State(state): State<Arc<AppState>>, |
|
| 269 | + | Query(q): Query<FlashQuery>, |
|
| 270 | + | ) -> Response { |
|
| 271 | + | let _ = state; // state available if needed later |
|
| 272 | + | ||
| 273 | + | let freshrss_configured = freshrss_env().is_some(); |
|
| 274 | + | ||
| 275 | + | let subscriptions = if freshrss_configured { |
|
| 276 | + | if let Some((url, user, pass)) = freshrss_env() { |
|
| 277 | + | feeds::fetch_freshrss_subscriptions(&url, &user, &pass) |
|
| 278 | + | .await |
|
| 279 | + | .ok() |
|
| 280 | + | .and_then(|list| list.subscriptions) |
|
| 281 | + | } else { |
|
| 282 | + | None |
|
| 283 | + | } |
|
| 284 | + | } else { |
|
| 285 | + | None |
|
| 286 | + | }; |
|
| 287 | + | ||
| 288 | + | Html( |
|
| 289 | + | AdminTemplate { |
|
| 290 | + | freshrss_configured, |
|
| 291 | + | success: q.success, |
|
| 292 | + | error: q.error, |
|
| 293 | + | subscriptions, |
|
| 294 | + | } |
|
| 295 | + | .render() |
|
| 296 | + | .unwrap(), |
|
| 297 | + | ) |
|
| 298 | + | .into_response() |
|
| 299 | + | } |
|
| 300 | + | ||
| 301 | + | async fn add_feed_handler( |
|
| 302 | + | _session: auth::AuthSession, |
|
| 303 | + | Form(form): Form<AddFeedForm>, |
|
| 304 | + | ) -> Response { |
|
| 305 | + | let (url, user, pass) = match freshrss_env() { |
|
| 306 | + | Some(env) => env, |
|
| 307 | + | None => { |
|
| 308 | + | return Redirect::to("/admin?error=FreshRSS+not+configured").into_response(); |
|
| 309 | + | } |
|
| 310 | + | }; |
|
| 311 | + | ||
| 312 | + | match feeds::add_freshrss_subscription(&url, &user, &pass, &form.feed_url).await { |
|
| 313 | + | Ok(_) => Redirect::to("/admin?success=Feed+added+successfully").into_response(), |
|
| 314 | + | Err(e) => { |
|
| 315 | + | eprintln!("Failed to add feed: {e}"); |
|
| 316 | + | let encoded = urlencoding::encode(&e); |
|
| 317 | + | Redirect::to(&format!("/admin?error={encoded}")).into_response() |
|
| 318 | + | } |
|
| 319 | + | } |
|
| 320 | + | } |
|
| 321 | + | ||
| 322 | + | #[tokio::main] |
|
| 323 | + | async fn main() { |
|
| 324 | + | dotenvy::dotenv().ok(); |
|
| 325 | + | ||
| 326 | + | let cookie_secure = std::env::var("COOKIE_SECURE") |
|
| 327 | + | .map(|v| v.eq_ignore_ascii_case("true")) |
|
| 328 | + | .unwrap_or(false); |
|
| 329 | + | ||
| 330 | + | let state = Arc::new(AppState { |
|
| 331 | + | sessions: auth::new_session_store(), |
|
| 332 | + | admin_password: std::env::var("ADMIN_PASSWORD").ok(), |
|
| 333 | + | cookie_secure, |
|
| 334 | + | }); |
|
| 335 | + | ||
| 336 | + | let app = Router::new() |
|
| 337 | + | .route("/", get(index_handler)) |
|
| 338 | + | .route("/feeds", get(feeds_handler)) |
|
| 339 | + | .route("/admin", get(admin_handler)) |
|
| 340 | + | .route( |
|
| 341 | + | "/admin/login", |
|
| 342 | + | get(login_get_handler).post(login_post_handler), |
|
| 343 | + | ) |
|
| 344 | + | .route("/admin/logout", get(logout_handler)) |
|
| 345 | + | .route("/admin/add-feed", post(add_feed_handler)) |
|
| 346 | + | .route("/assets/{*path}", get(static_handler)) |
|
| 347 | + | .with_state(state); |
|
| 348 | + | ||
| 349 | + | let listener = tokio::net::TcpListener::bind("0.0.0.0:4555") |
|
| 350 | + | .await |
|
| 351 | + | .expect("Failed to bind to port 4555"); |
|
| 352 | + | ||
| 353 | + | println!("Server running on http://localhost:4555"); |
|
| 354 | + | axum::serve(listener, app).await.unwrap(); |
|
| 355 | + | } |
| 1 | + | #![allow(dead_code)] |
|
| 2 | + | use serde::{Deserialize, Serialize}; |
|
| 3 | + | ||
| 4 | + | #[derive(Debug, Clone, Serialize, Deserialize)] |
|
| 5 | + | pub struct FeedItem { |
|
| 6 | + | pub id: String, |
|
| 7 | + | pub title: String, |
|
| 8 | + | pub published: i64, |
|
| 9 | + | pub author: String, |
|
| 10 | + | pub link: String, |
|
| 11 | + | pub origin: String, |
|
| 12 | + | } |
|
| 13 | + | ||
| 14 | + | #[derive(Debug, Deserialize)] |
|
| 15 | + | pub struct FreshRSSResponse { |
|
| 16 | + | pub id: String, |
|
| 17 | + | pub updated: Option<i64>, |
|
| 18 | + | pub items: Vec<FreshRSSItem>, |
|
| 19 | + | pub continuation: Option<String>, |
|
| 20 | + | } |
|
| 21 | + | ||
| 22 | + | #[derive(Debug, Deserialize)] |
|
| 23 | + | pub struct FreshRSSItem { |
|
| 24 | + | pub id: String, |
|
| 25 | + | pub title: String, |
|
| 26 | + | pub published: i64, |
|
| 27 | + | pub author: Option<String>, |
|
| 28 | + | pub canonical: Option<Vec<FreshRSSLink>>, |
|
| 29 | + | pub origin: FreshRSSOrigin, |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | #[derive(Debug, Deserialize)] |
|
| 33 | + | pub struct FreshRSSLink { |
|
| 34 | + | pub href: String, |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | #[derive(Debug, Clone, Deserialize)] |
|
| 38 | + | pub struct FreshRSSOrigin { |
|
| 39 | + | #[serde(rename = "streamId")] |
|
| 40 | + | pub stream_id: String, |
|
| 41 | + | #[serde(rename = "htmlUrl")] |
|
| 42 | + | pub html_url: Option<String>, |
|
| 43 | + | pub title: String, |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | #[derive(Debug, Serialize, Deserialize)] |
|
| 47 | + | pub struct Subscription { |
|
| 48 | + | pub id: String, |
|
| 49 | + | pub title: String, |
|
| 50 | + | pub url: String, |
|
| 51 | + | #[serde(rename = "htmlUrl", skip_serializing_if = "Option::is_none")] |
|
| 52 | + | pub html_url: Option<String>, |
|
| 53 | + | } |
|
| 54 | + | ||
| 55 | + | #[derive(Debug, Serialize, Deserialize)] |
|
| 56 | + | pub struct SubscriptionList { |
|
| 57 | + | pub subscriptions: Option<Vec<Subscription>>, |
|
| 58 | + | } |
| 1 | + | <!doctype html> |
|
| 2 | + | <html lang="en"> |
|
| 3 | + | <head> |
|
| 4 | + | <meta charset="UTF-8" /> |
|
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | + | <meta name="theme-color" content="#121113" /> |
|
| 7 | + | <link rel="stylesheet" href="/assets/styles.css" /> |
|
| 8 | + | <link rel="apple-touch-icon" sizes="180x180" href="https://feeds.stevedylan.dev/assets/apple-touch-icon.png"> |
|
| 9 | + | <link rel="icon" type="image/png" sizes="32x32" href="https://feeds.stevedylan.dev/assets/favicon-32x32.png"> |
|
| 10 | + | <link rel="icon" type="image/png" sizes="16x16" href="https://feeds.stevedylan.dev/assets/favicon-16x16.png"> |
|
| 11 | + | <link rel="manifest" href="https://feeds.stevedylan.dev/assets/site.webmanifest"> |
|
| 12 | + | <title>Feeds | Admin</title> |
|
| 13 | + | </head> |
|
| 14 | + | <body> |
|
| 15 | + | <a href="/" class="header"> |
|
| 16 | + | <h1>FEEDS</h1> |
|
| 17 | + | </a> |
|
| 18 | + | {% if !freshrss_configured %} |
|
| 19 | + | <div class="admin-notice"> |
|
| 20 | + | <p>FreshRSS is not configured. Set <code>FRESHRSS_URL</code>, <code>FRESHRSS_USERNAME</code>, and <code>FRESHRSS_PASSWORD</code> environment variables to use this feature.</p> |
|
| 21 | + | </div> |
|
| 22 | + | {% else %} |
|
| 23 | + | ||
| 24 | + | {% if let Some(msg) = success %} |
|
| 25 | + | <p class="success-msg">{{ msg }}</p> |
|
| 26 | + | {% endif %} |
|
| 27 | + | ||
| 28 | + | {% if let Some(err) = error %} |
|
| 29 | + | <p class="error-msg">{{ err }}</p> |
|
| 30 | + | {% endif %} |
|
| 31 | + | ||
| 32 | + | <form class="admin-form" method="POST" action="/admin/add-feed"> |
|
| 33 | + | <label for="feed_url">Feed URL</label> |
|
| 34 | + | <input type="url" id="feed_url" name="feed_url" placeholder="https://example.com/feed.xml" required /> |
|
| 35 | + | <button type="submit">Add Feed</button> |
|
| 36 | + | </form> |
|
| 37 | + | ||
| 38 | + | {% if let Some(subs) = subscriptions %} |
|
| 39 | + | <div class="admin-subs"> |
|
| 40 | + | <h3>Current Subscriptions ({{ subs.len() }})</h3> |
|
| 41 | + | <div class="feeds-list"> |
|
| 42 | + | {% for sub in subs %} |
|
| 43 | + | <div class="feed-item"> |
|
| 44 | + | <h3 class="feed-title"> |
|
| 45 | + | <a href="{{ sub.url }}" target="_blank" rel="noopener noreferrer">{{ sub.title }}</a> |
|
| 46 | + | </h3> |
|
| 47 | + | </div> |
|
| 48 | + | {% endfor %} |
|
| 49 | + | </div> |
|
| 50 | + | </div> |
|
| 51 | + | {% endif %} |
|
| 52 | + | ||
| 53 | + | {% endif %} |
|
| 54 | + | </body> |
|
| 55 | + | </html> |
| 1 | + | <!doctype html> |
|
| 2 | + | <html lang="en"> |
|
| 3 | + | <head> |
|
| 4 | + | <meta charset="UTF-8" /> |
|
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | + | <meta name="theme-color" content="#121113" /> |
|
| 7 | + | <link rel="stylesheet" href="/assets/styles.css" /> |
|
| 8 | + | <link rel="apple-touch-icon" sizes="180x180" href="https://feeds.stevedylan.dev/assets/apple-touch-icon.png"> |
|
| 9 | + | <link rel="icon" type="image/png" sizes="32x32" href="https://feeds.stevedylan.dev/assets/favicon-32x32.png"> |
|
| 10 | + | <link rel="icon" type="image/png" sizes="16x16" href="https://feeds.stevedylan.dev/assets/favicon-16x16.png"> |
|
| 11 | + | <link rel="manifest" href="https://feeds.stevedylan.dev/assets/site.webmanifest"> |
|
| 12 | + | ||
| 13 | + | <title>Feeds</title> |
|
| 14 | + | <meta name="description" content="Minimal RSS Reading"> |
|
| 15 | + | ||
| 16 | + | <meta property="og:url" content="https://feeds.stevedylan.dev"> |
|
| 17 | + | <meta property="og:type" content="website"> |
|
| 18 | + | <meta property="og:title" content="Feeds"> |
|
| 19 | + | <meta property="og:description" content="Minimal RSS Reading"> |
|
| 20 | + | <meta property="og:image" content="https://feeds.stevedylan.dev/assets/og.png"> |
|
| 21 | + | ||
| 22 | + | <meta name="twitter:card" content="summary_large_image"> |
|
| 23 | + | <meta property="twitter:domain" content="feeds.stevedylan.dev"> |
|
| 24 | + | <meta property="twitter:url" content="https://feeds.stevedylan.dev"> |
|
| 25 | + | <meta name="twitter:title" content="Feeds"> |
|
| 26 | + | <meta name="twitter:description" content="Minimal RSS Reading"> |
|
| 27 | + | <meta name="twitter:image" content="https://feeds.stevedylan.dev/assets/og.png"> |
|
| 28 | + | </head> |
|
| 29 | + | <body> |
|
| 30 | + | <a href="/" class="header"> |
|
| 31 | + | <h1>FEEDS</h1> |
|
| 32 | + | </a> |
|
| 33 | + | ||
| 34 | + | {% if let Some(urls) = feed_urls %} |
|
| 35 | + | <div id="feed-urls"> |
|
| 36 | + | {% for url in urls %} |
|
| 37 | + | {{ url }}<br> |
|
| 38 | + | {% endfor %} |
|
| 39 | + | </div> |
|
| 40 | + | {% endif %} |
|
| 41 | + | ||
| 42 | + | {% if let Some(err) = error %} |
|
| 43 | + | <div id="error" style="color: #ff6b6b;"> |
|
| 44 | + | <p>{{ err }}</p> |
|
| 45 | + | </div> |
|
| 46 | + | {% elif items.is_empty() %} |
|
| 47 | + | <p class="no-feeds">No feeds available</p> |
|
| 48 | + | {% else %} |
|
| 49 | + | <div id="feeds-container"> |
|
| 50 | + | <div class="feeds-list"> |
|
| 51 | + | {% for item in items %} |
|
| 52 | + | <article class="feed-item"> |
|
| 53 | + | <div class="feed-meta"> |
|
| 54 | + | <span class="feed-date">{{ item.formatted_date }}</span> |
|
| 55 | + | </div> |
|
| 56 | + | <h3 class="feed-title"> |
|
| 57 | + | <a href="{{ item.link }}" target="_blank" rel="noopener noreferrer"> |
|
| 58 | + | {{ item.title }} |
|
| 59 | + | </a> |
|
| 60 | + | </h3> |
|
| 61 | + | {% if !item.author.is_empty() %} |
|
| 62 | + | <p class="feed-author">{{ item.author }}</p> |
|
| 63 | + | {% endif %} |
|
| 64 | + | </article> |
|
| 65 | + | {% endfor %} |
|
| 66 | + | </div> |
|
| 67 | + | </div> |
|
| 68 | + | {% endif %} |
|
| 69 | + | </body> |
|
| 70 | + | </html> |
| 1 | + | <!doctype html> |
|
| 2 | + | <html lang="en"> |
|
| 3 | + | <head> |
|
| 4 | + | <meta charset="UTF-8" /> |
|
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | + | <meta name="theme-color" content="#121113" /> |
|
| 7 | + | <link rel="stylesheet" href="/assets/styles.css" /> |
|
| 8 | + | <link rel="apple-touch-icon" sizes="180x180" href="https://feeds.stevedylan.dev/assets/apple-touch-icon.png"> |
|
| 9 | + | <link rel="icon" type="image/png" sizes="32x32" href="https://feeds.stevedylan.dev/assets/favicon-32x32.png"> |
|
| 10 | + | <link rel="icon" type="image/png" sizes="16x16" href="https://feeds.stevedylan.dev/assets/favicon-16x16.png"> |
|
| 11 | + | <link rel="manifest" href="https://feeds.stevedylan.dev/assets/site.webmanifest"> |
|
| 12 | + | <title>Feeds | Login</title> |
|
| 13 | + | </head> |
|
| 14 | + | <body> |
|
| 15 | + | <a href="/" class="header"> |
|
| 16 | + | <h1>FEEDS</h1> |
|
| 17 | + | </a> |
|
| 18 | + | {% if let Some(err) = error %} |
|
| 19 | + | <p class="error-msg">{{ err }}</p> |
|
| 20 | + | {% endif %} |
|
| 21 | + | ||
| 22 | + | <form class="admin-form" method="POST" action="/admin/login"> |
|
| 23 | + | <label for="password">Password</label> |
|
| 24 | + | <input type="password" id="password" name="password" required autofocus /> |
|
| 25 | + | <button type="submit">Login</button> |
|
| 26 | + | </form> |
|
| 27 | + | </body> |
|
| 28 | + | </html> |
| 1 | + | JOTTS_PASSWORD=changeme |
|
| 2 | + | JOTTS_DB_PATH=jotts.sqlite |
|
| 3 | + | COOKIE_SECURE=false |
|
| 4 | + | HOST=127.0.0.1 |
|
| 5 | + | PORT=3000 |
| 1 | + | [package] |
|
| 2 | + | name = "jotts" |
|
| 3 | + | version = "0.1.0" |
|
| 4 | + | edition = "2024" |
|
| 5 | + | ||
| 6 | + | [dependencies] |
|
| 7 | + | axum = { workspace = true } |
|
| 8 | + | tokio = { workspace = true } |
|
| 9 | + | serde = { workspace = true } |
|
| 10 | + | serde_json = { workspace = true } |
|
| 11 | + | rusqlite = { workspace = true } |
|
| 12 | + | nanoid = { workspace = true } |
|
| 13 | + | rust-embed = { workspace = true } |
|
| 14 | + | dotenvy = { workspace = true } |
|
| 15 | + | subtle = { workspace = true } |
|
| 16 | + | rand = { workspace = true } |
|
| 17 | + | tracing = { workspace = true } |
|
| 18 | + | tracing-subscriber = { workspace = true } |
|
| 19 | + | andromeda-auth = { workspace = true } |
|
| 20 | + | askama = "0.15" |
|
| 21 | + | askama_web = { version = "0.15", features = ["axum-0.8"] } |
|
| 22 | + | pulldown-cmark = "0.12" |
| 1 | + | # Build from repo root: docker build -t jotts -f apps/jotts/Dockerfile . |
|
| 2 | + | FROM rust:1-slim-bookworm AS builder |
|
| 3 | + | WORKDIR /app |
|
| 4 | + | ||
| 5 | + | # Copy workspace manifests |
|
| 6 | + | COPY Cargo.toml Cargo.lock ./ |
|
| 7 | + | COPY crates/auth/Cargo.toml crates/auth/ |
|
| 8 | + | COPY apps/sipp/Cargo.toml apps/sipp/ |
|
| 9 | + | COPY apps/feeds/Cargo.toml apps/feeds/ |
|
| 10 | + | COPY apps/parcels/Cargo.toml apps/parcels/ |
|
| 11 | + | COPY apps/jotts/Cargo.toml apps/jotts/ |
|
| 12 | + | COPY apps/og/Cargo.toml apps/og/ |
|
| 13 | + | COPY apps/shrink/Cargo.toml apps/shrink/ |
|
| 14 | + | ||
| 15 | + | # Create stubs for dependency caching |
|
| 16 | + | RUN mkdir -p crates/auth/src && echo '' > crates/auth/src/lib.rs \ |
|
| 17 | + | && for app in sipp feeds parcels jotts og shrink; do \ |
|
| 18 | + | mkdir -p apps/$app/src && echo 'fn main() {}' > apps/$app/src/main.rs; \ |
|
| 19 | + | done |
|
| 20 | + | ||
| 21 | + | RUN cargo build --release -p jotts |
|
| 22 | + | ||
| 23 | + | # Copy real source |
|
| 24 | + | COPY crates/auth/src crates/auth/src |
|
| 25 | + | COPY apps/jotts/src apps/jotts/src |
|
| 26 | + | COPY apps/jotts/assets apps/jotts/assets |
|
| 27 | + | COPY apps/jotts/static apps/jotts/static |
|
| 28 | + | COPY apps/jotts/templates apps/jotts/templates |
|
| 29 | + | ||
| 30 | + | RUN touch apps/jotts/src/*.rs crates/auth/src/*.rs && cargo build --release -p jotts |
|
| 31 | + | ||
| 32 | + | FROM debian:bookworm-slim |
|
| 33 | + | COPY --from=builder /app/target/release/jotts /usr/local/bin/jotts |
|
| 34 | + | WORKDIR /data |
|
| 35 | + | EXPOSE 3000 |
|
| 36 | + | ENV HOST=0.0.0.0 |
|
| 37 | + | ENV PORT=3000 |
|
| 38 | + | CMD ["jotts"] |
| 1 | + | MIT License |
|
| 2 | + | ||
| 3 | + | Copyright (c) 2026 Steve Simkins |
|
| 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 | + |
| 1 | + | # Jotts |
|
| 2 | + | ||
| 3 | + |  |
|
| 4 | + | ||
| 5 | + | A minimal notes app |
|
| 6 | + | ||
| 7 | + | ## Quickstart |
|
| 8 | + | ||
| 9 | + | ```bash |
|
| 10 | + | git clone https://github.com/stevedylandev/jotts.git |
|
| 11 | + | cd jotts |
|
| 12 | + | cp .env.example .env |
|
| 13 | + | # Edit .env with your password |
|
| 14 | + | cargo build --release |
|
| 15 | + | ./target/release/jotts |
|
| 16 | + | ``` |
|
| 17 | + | ||
| 18 | + | ### Environment Variables |
|
| 19 | + | ||
| 20 | + | | Variable | Description | Default | |
|
| 21 | + | |---|---|---| |
|
| 22 | + | | `JOTTS_PASSWORD` | Password for login authentication | `changeme` | |
|
| 23 | + | | `JOTTS_DB_PATH` | SQLite database file path | `jotts.sqlite` | |
|
| 24 | + | | `HOST` | Server bind address | `127.0.0.1` | |
|
| 25 | + | | `PORT` | Server port | `3000` | |
|
| 26 | + | | `COOKIE_SECURE` | Enable HTTPS-only cookies | `false` | |
|
| 27 | + | ||
| 28 | + | ## Overview |
|
| 29 | + | ||
| 30 | + | A simple, self-hosted markdown note app built with Rust. Here's a few highlights: |
|
| 31 | + | - Single ~7MB Rust binary with embedded assets |
|
| 32 | + | - Password authentication with session cookies |
|
| 33 | + | - Create, edit, and delete markdown notes |
|
| 34 | + | - Markdown rendering with strikethrough, tables, and task lists |
|
| 35 | + | - Dark themed UI with Commit Mono font |
|
| 36 | + | - SQLite for persistent storage |
|
| 37 | + | ||
| 38 | + | ## Structure |
|
| 39 | + | ||
| 40 | + | ``` |
|
| 41 | + | jotts/ |
|
| 42 | + | ├── src/ |
|
| 43 | + | │ ├── main.rs # App entrypoint, env vars, starts server |
|
| 44 | + | │ ├── server.rs # Axum router, HTTP handlers, and templates |
|
| 45 | + | │ ├── auth.rs # Password verification and session management |
|
| 46 | + | │ └── db.rs # SQLite database layer (notes, sessions) |
|
| 47 | + | ├── templates/ # Askama HTML templates |
|
| 48 | + | │ ├── base.html # Base layout with header and nav |
|
| 49 | + | │ ├── login.html # Login page |
|
| 50 | + | │ ├── index.html # Note list |
|
| 51 | + | │ ├── view.html # Single note display |
|
| 52 | + | │ ├── new.html # Create note form |
|
| 53 | + | │ └── edit.html # Edit note form |
|
| 54 | + | ├── static/ # Favicons, og:image, styles, and webmanifest |
|
| 55 | + | ├── assets/ # Commit Mono font files |
|
| 56 | + | ├── Dockerfile # Multi-stage build (Rust + Debian slim) |
|
| 57 | + | └── docker-compose.yml |
|
| 58 | + | ``` |
|
| 59 | + | ||
| 60 | + | ## Deployment |
|
| 61 | + | ||
| 62 | + | ### Docker (recommended) |
|
| 63 | + | ||
| 64 | + | ```bash |
|
| 65 | + | git clone https://github.com/stevedylandev/jotts.git |
|
| 66 | + | cd jotts |
|
| 67 | + | cp .env.example .env |
|
| 68 | + | # Edit .env with your password |
|
| 69 | + | docker compose up -d |
|
| 70 | + | ``` |
|
| 71 | + | ||
| 72 | + | This will start Jotts on port `3000` with a persistent volume for the SQLite database. |
|
| 73 | + | ||
| 74 | + | ### Binary |
|
| 75 | + | ||
| 76 | + | ```bash |
|
| 77 | + | cargo build --release |
|
| 78 | + | ``` |
|
| 79 | + | ||
| 80 | + | The resulting binary at `./target/release/jotts` is self-contained with all assets embedded. Copy it to your server with a configured `.env` file and run it directly. |
|
| 81 | + | ||
| 82 | + | ## License |
|
| 83 | + | ||
| 84 | + | [MIT](LICENSE) |
Binary file — no preview.
Binary file — no preview.
| 1 | + | services: |
|
| 2 | + | app: |
|
| 3 | + | build: |
|
| 4 | + | context: ../.. |
|
| 5 | + | dockerfile: apps/jotts/Dockerfile |
|
| 6 | + | ports: |
|
| 7 | + | - "${PORT:-3000}:${PORT:-3000}" |
|
| 8 | + | environment: |
|
| 9 | + | - JOTTS_PASSWORD=${JOTTS_PASSWORD:-changeme} |
|
| 10 | + | - JOTTS_DB_PATH=/data/jotts.sqlite |
|
| 11 | + | - COOKIE_SECURE=false |
|
| 12 | + | - HOST=0.0.0.0 |
|
| 13 | + | - PORT=${PORT:-3000} |
|
| 14 | + | volumes: |
|
| 15 | + | - jotts-data:/data |
|
| 16 | + | restart: unless-stopped |
|
| 17 | + | ||
| 18 | + | volumes: |
|
| 19 | + | jotts-data: |
| 1 | + | use axum::{ |
|
| 2 | + | extract::FromRequestParts, |
|
| 3 | + | http::request::Parts, |
|
| 4 | + | response::{IntoResponse, Redirect, Response}, |
|
| 5 | + | }; |
|
| 6 | + | use std::sync::Arc; |
|
| 7 | + | ||
| 8 | + | use crate::db; |
|
| 9 | + | use crate::server::AppState; |
|
| 10 | + | ||
| 11 | + | pub use andromeda_auth::{ |
|
| 12 | + | build_session_cookie, clear_session_cookie, generate_session_token, verify_password, |
|
| 13 | + | }; |
|
| 14 | + | ||
| 15 | + | pub struct AuthSession; |
|
| 16 | + | ||
| 17 | + | impl FromRequestParts<Arc<AppState>> for AuthSession { |
|
| 18 | + | type Rejection = Response; |
|
| 19 | + | ||
| 20 | + | async fn from_request_parts( |
|
| 21 | + | parts: &mut Parts, |
|
| 22 | + | state: &Arc<AppState>, |
|
| 23 | + | ) -> Result<Self, Self::Rejection> { |
|
| 24 | + | let token = andromeda_auth::extract_session_cookie(&parts.headers); |
|
| 25 | + | if let Some(token) = token { |
|
| 26 | + | if is_valid_session(state, &token) { |
|
| 27 | + | return Ok(AuthSession); |
|
| 28 | + | } |
|
| 29 | + | } |
|
| 30 | + | Err(Redirect::to("/login").into_response()) |
|
| 31 | + | } |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | fn is_valid_session(state: &AppState, token: &str) -> bool { |
|
| 35 | + | match db::get_session_expiry(&state.db, token) { |
|
| 36 | + | Ok(Some(expires_at)) => { |
|
| 37 | + | let now = chrono_now(); |
|
| 38 | + | expires_at > now |
|
| 39 | + | } |
|
| 40 | + | _ => false, |
|
| 41 | + | } |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | fn chrono_now() -> String { |
|
| 45 | + | use std::time::{SystemTime, UNIX_EPOCH}; |
|
| 46 | + | let secs = SystemTime::now() |
|
| 47 | + | .duration_since(UNIX_EPOCH) |
|
| 48 | + | .unwrap() |
|
| 49 | + | .as_secs(); |
|
| 50 | + | let days_since_epoch = secs / 86400; |
|
| 51 | + | let time_of_day = secs % 86400; |
|
| 52 | + | let hours = time_of_day / 3600; |
|
| 53 | + | let minutes = (time_of_day % 3600) / 60; |
|
| 54 | + | let seconds = time_of_day % 60; |
|
| 55 | + | ||
| 56 | + | let (year, month, day) = days_to_ymd(days_since_epoch as i64); |
|
| 57 | + | format!( |
|
| 58 | + | "{:04}-{:02}-{:02} {:02}:{:02}:{:02}", |
|
| 59 | + | year, month, day, hours, minutes, seconds |
|
| 60 | + | ) |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | fn days_to_ymd(mut days: i64) -> (i64, i64, i64) { |
|
| 64 | + | days += 719468; |
|
| 65 | + | let era = if days >= 0 { days } else { days - 146096 } / 146097; |
|
| 66 | + | let doe = (days - era * 146097) as u32; |
|
| 67 | + | let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; |
|
| 68 | + | let y = yoe as i64 + era * 400; |
|
| 69 | + | let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); |
|
| 70 | + | let mp = (5 * doy + 2) / 153; |
|
| 71 | + | let d = doy - (153 * mp + 2) / 5 + 1; |
|
| 72 | + | let m = if mp < 10 { mp + 3 } else { mp - 9 }; |
|
| 73 | + | let y = if m <= 2 { y + 1 } else { y }; |
|
| 74 | + | (y, m as i64, d as i64) |
|
| 75 | + | } |
| 1 | + | use nanoid::nanoid; |
|
| 2 | + | use rusqlite::{Connection, params}; |
|
| 3 | + | use serde::{Deserialize, Serialize}; |
|
| 4 | + | use std::fmt; |
|
| 5 | + | use std::sync::{Arc, Mutex}; |
|
| 6 | + | ||
| 7 | + | pub type Db = Arc<Mutex<Connection>>; |
|
| 8 | + | ||
| 9 | + | #[derive(Debug)] |
|
| 10 | + | pub enum DbError { |
|
| 11 | + | Sqlite(rusqlite::Error), |
|
| 12 | + | LockPoisoned, |
|
| 13 | + | } |
|
| 14 | + | ||
| 15 | + | impl fmt::Display for DbError { |
|
| 16 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
|
| 17 | + | match self { |
|
| 18 | + | DbError::Sqlite(e) => write!(f, "Database error: {}", e), |
|
| 19 | + | DbError::LockPoisoned => write!(f, "Database lock poisoned"), |
|
| 20 | + | } |
|
| 21 | + | } |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | impl std::error::Error for DbError {} |
|
| 25 | + | ||
| 26 | + | impl From<rusqlite::Error> for DbError { |
|
| 27 | + | fn from(e: rusqlite::Error) -> Self { |
|
| 28 | + | DbError::Sqlite(e) |
|
| 29 | + | } |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | #[derive(Debug, Serialize, Deserialize)] |
|
| 33 | + | pub struct Note { |
|
| 34 | + | pub id: i64, |
|
| 35 | + | pub short_id: String, |
|
| 36 | + | pub title: String, |
|
| 37 | + | pub content: String, |
|
| 38 | + | pub created_at: String, |
|
| 39 | + | pub updated_at: String, |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | pub fn init_db() -> Db { |
|
| 43 | + | let path = std::env::var("JOTTS_DB_PATH").unwrap_or_else(|_| "jotts.sqlite".to_string()); |
|
| 44 | + | let conn = Connection::open(&path).expect("Failed to open database"); |
|
| 45 | + | ||
| 46 | + | conn.execute_batch( |
|
| 47 | + | "CREATE TABLE IF NOT EXISTS notes ( |
|
| 48 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 49 | + | short_id TEXT NOT NULL UNIQUE, |
|
| 50 | + | title TEXT NOT NULL, |
|
| 51 | + | content TEXT NOT NULL, |
|
| 52 | + | created_at TEXT NOT NULL DEFAULT (datetime('now')), |
|
| 53 | + | updated_at TEXT NOT NULL DEFAULT (datetime('now')) |
|
| 54 | + | ); |
|
| 55 | + | ||
| 56 | + | CREATE TABLE IF NOT EXISTS sessions ( |
|
| 57 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 58 | + | token TEXT NOT NULL UNIQUE, |
|
| 59 | + | expires_at TEXT NOT NULL |
|
| 60 | + | );" |
|
| 61 | + | ) |
|
| 62 | + | .expect("Failed to create tables"); |
|
| 63 | + | ||
| 64 | + | Arc::new(Mutex::new(conn)) |
|
| 65 | + | } |
|
| 66 | + | ||
| 67 | + | pub fn create_note(db: &Db, title: &str, content: &str) -> Result<Note, DbError> { |
|
| 68 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 69 | + | let short_id = nanoid!(10); |
|
| 70 | + | conn.execute( |
|
| 71 | + | "INSERT INTO notes (short_id, title, content) VALUES (?1, ?2, ?3)", |
|
| 72 | + | params![short_id, title, content], |
|
| 73 | + | )?; |
|
| 74 | + | let id = conn.last_insert_rowid(); |
|
| 75 | + | let note = conn.query_row( |
|
| 76 | + | "SELECT id, short_id, title, content, created_at, updated_at FROM notes WHERE id = ?1", |
|
| 77 | + | params![id], |
|
| 78 | + | |row| { |
|
| 79 | + | Ok(Note { |
|
| 80 | + | id: row.get(0)?, |
|
| 81 | + | short_id: row.get(1)?, |
|
| 82 | + | title: row.get(2)?, |
|
| 83 | + | content: row.get(3)?, |
|
| 84 | + | created_at: row.get(4)?, |
|
| 85 | + | updated_at: row.get(5)?, |
|
| 86 | + | }) |
|
| 87 | + | }, |
|
| 88 | + | )?; |
|
| 89 | + | Ok(note) |
|
| 90 | + | } |
|
| 91 | + | ||
| 92 | + | pub fn get_note_by_short_id(db: &Db, short_id: &str) -> Result<Option<Note>, DbError> { |
|
| 93 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 94 | + | match conn.query_row( |
|
| 95 | + | "SELECT id, short_id, title, content, created_at, updated_at FROM notes WHERE short_id = ?1", |
|
| 96 | + | params![short_id], |
|
| 97 | + | |row| { |
|
| 98 | + | Ok(Note { |
|
| 99 | + | id: row.get(0)?, |
|
| 100 | + | short_id: row.get(1)?, |
|
| 101 | + | title: row.get(2)?, |
|
| 102 | + | content: row.get(3)?, |
|
| 103 | + | created_at: row.get(4)?, |
|
| 104 | + | updated_at: row.get(5)?, |
|
| 105 | + | }) |
|
| 106 | + | }, |
|
| 107 | + | ) { |
|
| 108 | + | Ok(note) => Ok(Some(note)), |
|
| 109 | + | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
|
| 110 | + | Err(e) => Err(DbError::Sqlite(e)), |
|
| 111 | + | } |
|
| 112 | + | } |
|
| 113 | + | ||
| 114 | + | pub fn get_all_notes(db: &Db) -> Result<Vec<Note>, DbError> { |
|
| 115 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 116 | + | let mut stmt = conn.prepare( |
|
| 117 | + | "SELECT id, short_id, title, content, created_at, updated_at FROM notes ORDER BY id DESC", |
|
| 118 | + | )?; |
|
| 119 | + | let notes = stmt |
|
| 120 | + | .query_map([], |row| { |
|
| 121 | + | Ok(Note { |
|
| 122 | + | id: row.get(0)?, |
|
| 123 | + | short_id: row.get(1)?, |
|
| 124 | + | title: row.get(2)?, |
|
| 125 | + | content: row.get(3)?, |
|
| 126 | + | created_at: row.get(4)?, |
|
| 127 | + | updated_at: row.get(5)?, |
|
| 128 | + | }) |
|
| 129 | + | })? |
|
| 130 | + | .collect::<Result<Vec<_>, _>>()?; |
|
| 131 | + | Ok(notes) |
|
| 132 | + | } |
|
| 133 | + | ||
| 134 | + | pub fn update_note_by_short_id( |
|
| 135 | + | db: &Db, |
|
| 136 | + | short_id: &str, |
|
| 137 | + | title: &str, |
|
| 138 | + | content: &str, |
|
| 139 | + | ) -> Result<Option<Note>, DbError> { |
|
| 140 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 141 | + | let rows = conn.execute( |
|
| 142 | + | "UPDATE notes SET title = ?1, content = ?2, updated_at = datetime('now') WHERE short_id = ?3", |
|
| 143 | + | params![title, content, short_id], |
|
| 144 | + | )?; |
|
| 145 | + | if rows == 0 { |
|
| 146 | + | return Ok(None); |
|
| 147 | + | } |
|
| 148 | + | match conn.query_row( |
|
| 149 | + | "SELECT id, short_id, title, content, created_at, updated_at FROM notes WHERE short_id = ?1", |
|
| 150 | + | params![short_id], |
|
| 151 | + | |row| { |
|
| 152 | + | Ok(Note { |
|
| 153 | + | id: row.get(0)?, |
|
| 154 | + | short_id: row.get(1)?, |
|
| 155 | + | title: row.get(2)?, |
|
| 156 | + | content: row.get(3)?, |
|
| 157 | + | created_at: row.get(4)?, |
|
| 158 | + | updated_at: row.get(5)?, |
|
| 159 | + | }) |
|
| 160 | + | }, |
|
| 161 | + | ) { |
|
| 162 | + | Ok(note) => Ok(Some(note)), |
|
| 163 | + | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
|
| 164 | + | Err(e) => Err(DbError::Sqlite(e)), |
|
| 165 | + | } |
|
| 166 | + | } |
|
| 167 | + | ||
| 168 | + | pub fn delete_note_by_short_id(db: &Db, short_id: &str) -> Result<bool, DbError> { |
|
| 169 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 170 | + | let rows = conn.execute( |
|
| 171 | + | "DELETE FROM notes WHERE short_id = ?1", |
|
| 172 | + | params![short_id], |
|
| 173 | + | )?; |
|
| 174 | + | Ok(rows > 0) |
|
| 175 | + | } |
|
| 176 | + | ||
| 177 | + | // Session functions |
|
| 178 | + | ||
| 179 | + | pub fn insert_session(db: &Db, token: &str, expires_at: &str) -> Result<(), DbError> { |
|
| 180 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 181 | + | conn.execute( |
|
| 182 | + | "INSERT INTO sessions (token, expires_at) VALUES (?1, ?2)", |
|
| 183 | + | params![token, expires_at], |
|
| 184 | + | )?; |
|
| 185 | + | Ok(()) |
|
| 186 | + | } |
|
| 187 | + | ||
| 188 | + | pub fn get_session_expiry(db: &Db, token: &str) -> Result<Option<String>, DbError> { |
|
| 189 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 190 | + | match conn.query_row( |
|
| 191 | + | "SELECT expires_at FROM sessions WHERE token = ?1", |
|
| 192 | + | params![token], |
|
| 193 | + | |row| row.get(0), |
|
| 194 | + | ) { |
|
| 195 | + | Ok(val) => Ok(Some(val)), |
|
| 196 | + | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
|
| 197 | + | Err(e) => Err(DbError::Sqlite(e)), |
|
| 198 | + | } |
|
| 199 | + | } |
|
| 200 | + | ||
| 201 | + | pub fn delete_session(db: &Db, token: &str) -> Result<(), DbError> { |
|
| 202 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 203 | + | conn.execute("DELETE FROM sessions WHERE token = ?1", params![token])?; |
|
| 204 | + | Ok(()) |
|
| 205 | + | } |
|
| 206 | + | ||
| 207 | + | pub fn prune_expired_sessions(db: &Db) -> Result<(), DbError> { |
|
| 208 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 209 | + | conn.execute( |
|
| 210 | + | "DELETE FROM sessions WHERE expires_at < datetime('now')", |
|
| 211 | + | [], |
|
| 212 | + | )?; |
|
| 213 | + | Ok(()) |
|
| 214 | + | } |
| 1 | + | mod auth; |
|
| 2 | + | mod db; |
|
| 3 | + | mod server; |
|
| 4 | + | ||
| 5 | + | #[tokio::main] |
|
| 6 | + | async fn main() { |
|
| 7 | + | tracing_subscriber::fmt::init(); |
|
| 8 | + | let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); |
|
| 9 | + | let port: u16 = std::env::var("PORT") |
|
| 10 | + | .ok() |
|
| 11 | + | .and_then(|v| v.parse().ok()) |
|
| 12 | + | .unwrap_or(3000); |
|
| 13 | + | server::run(host, port).await; |
|
| 14 | + | } |
| 1 | + | use askama::Template; |
|
| 2 | + | use askama_web::WebTemplate; |
|
| 3 | + | use axum::{ |
|
| 4 | + | extract::{Form, Path, Query, State}, |
|
| 5 | + | http::{HeaderValue, StatusCode}, |
|
| 6 | + | response::{Html, IntoResponse, Redirect, Response}, |
|
| 7 | + | routing::{get, post}, |
|
| 8 | + | Router, |
|
| 9 | + | }; |
|
| 10 | + | use pulldown_cmark::{Options, Parser, html}; use rust_embed::Embed; |
|
| 11 | + | use std::sync::Arc; |
|
| 12 | + | ||
| 13 | + | use crate::auth; |
|
| 14 | + | use crate::db::{self, Db, Note}; |
|
| 15 | + | ||
| 16 | + | #[derive(Clone)] |
|
| 17 | + | pub struct AppState { |
|
| 18 | + | pub db: Db, |
|
| 19 | + | pub app_password: String, |
|
| 20 | + | pub cookie_secure: bool, |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | #[derive(Embed)] |
|
| 24 | + | #[folder = "assets/"] |
|
| 25 | + | struct Assets; |
|
| 26 | + | ||
| 27 | + | #[derive(Embed)] |
|
| 28 | + | #[folder = "static/"] |
|
| 29 | + | struct Static; |
|
| 30 | + | ||
| 31 | + | // --- Templates --- |
|
| 32 | + | ||
| 33 | + | #[derive(Template)] |
|
| 34 | + | #[template(path = "base.html")] |
|
| 35 | + | struct BaseTemplate; |
|
| 36 | + | ||
| 37 | + | #[derive(Template)] |
|
| 38 | + | #[template(path = "login.html")] |
|
| 39 | + | struct LoginTemplate { |
|
| 40 | + | error: Option<String>, |
|
| 41 | + | } |
|
| 42 | + | ||
| 43 | + | #[derive(Template)] |
|
| 44 | + | #[template(path = "index.html")] |
|
| 45 | + | struct IndexTemplate { |
|
| 46 | + | notes: Vec<Note>, |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | #[derive(Template)] |
|
| 50 | + | #[template(path = "view.html")] |
|
| 51 | + | struct ViewTemplate { |
|
| 52 | + | note: Note, |
|
| 53 | + | rendered_content: String, |
|
| 54 | + | } |
|
| 55 | + | ||
| 56 | + | #[derive(Template)] |
|
| 57 | + | #[template(path = "new.html")] |
|
| 58 | + | struct NewTemplate { |
|
| 59 | + | error: Option<String>, |
|
| 60 | + | } |
|
| 61 | + | ||
| 62 | + | #[derive(Template)] |
|
| 63 | + | #[template(path = "edit.html")] |
|
| 64 | + | struct EditTemplate { |
|
| 65 | + | note: Note, |
|
| 66 | + | error: Option<String>, |
|
| 67 | + | } |
|
| 68 | + | ||
| 69 | + | // --- Query/Form structs --- |
|
| 70 | + | ||
| 71 | + | #[derive(serde::Deserialize, Default)] |
|
| 72 | + | pub struct FlashQuery { |
|
| 73 | + | pub error: Option<String>, |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | #[derive(serde::Deserialize)] |
|
| 77 | + | struct LoginForm { |
|
| 78 | + | password: String, |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | #[derive(serde::Deserialize)] |
|
| 82 | + | struct NoteForm { |
|
| 83 | + | title: String, |
|
| 84 | + | content: String, |
|
| 85 | + | } |
|
| 86 | + | ||
| 87 | + | // --- Static file handlers --- |
|
| 88 | + | ||
| 89 | + | fn mime_from_path(path: &str) -> &'static str { |
|
| 90 | + | match path.rsplit('.').next().unwrap_or("") { |
|
| 91 | + | "css" => "text/css", |
|
| 92 | + | "js" => "application/javascript", |
|
| 93 | + | "html" => "text/html", |
|
| 94 | + | "png" => "image/png", |
|
| 95 | + | "ico" => "image/x-icon", |
|
| 96 | + | "svg" => "image/svg+xml", |
|
| 97 | + | "woff" | "woff2" => "font/woff2", |
|
| 98 | + | "ttf" => "font/ttf", |
|
| 99 | + | "otf" => "font/otf", |
|
| 100 | + | "json" | "webmanifest" => "application/json", |
|
| 101 | + | _ => "application/octet-stream", |
|
| 102 | + | } |
|
| 103 | + | } |
|
| 104 | + | ||
| 105 | + | async fn serve_asset(Path(path): Path<String>) -> Response { |
|
| 106 | + | match Assets::get(&path) { |
|
| 107 | + | Some(file) => { |
|
| 108 | + | let mime = mime_from_path(&path); |
|
| 109 | + | ( |
|
| 110 | + | StatusCode::OK, |
|
| 111 | + | [(axum::http::header::CONTENT_TYPE, HeaderValue::from_static(mime))], |
|
| 112 | + | file.data.to_vec(), |
|
| 113 | + | ) |
|
| 114 | + | .into_response() |
|
| 115 | + | } |
|
| 116 | + | None => StatusCode::NOT_FOUND.into_response(), |
|
| 117 | + | } |
|
| 118 | + | } |
|
| 119 | + | ||
| 120 | + | async fn serve_static(Path(path): Path<String>) -> Response { |
|
| 121 | + | match Static::get(&path) { |
|
| 122 | + | Some(file) => { |
|
| 123 | + | let mime = mime_from_path(&path); |
|
| 124 | + | ( |
|
| 125 | + | StatusCode::OK, |
|
| 126 | + | [(axum::http::header::CONTENT_TYPE, HeaderValue::from_static(mime))], |
|
| 127 | + | file.data.to_vec(), |
|
| 128 | + | ) |
|
| 129 | + | .into_response() |
|
| 130 | + | } |
|
| 131 | + | None => StatusCode::NOT_FOUND.into_response(), |
|
| 132 | + | } |
|
| 133 | + | } |
|
| 134 | + | ||
| 135 | + | // --- Auth handlers --- |
|
| 136 | + | ||
| 137 | + | async fn get_login(Query(q): Query<FlashQuery>) -> Response { |
|
| 138 | + | WebTemplate(LoginTemplate { error: q.error }).into_response() |
|
| 139 | + | } |
|
| 140 | + | ||
| 141 | + | async fn post_login( |
|
| 142 | + | State(state): State<Arc<AppState>>, |
|
| 143 | + | Form(form): Form<LoginForm>, |
|
| 144 | + | ) -> Response { |
|
| 145 | + | if !auth::verify_password(&form.password, &state.app_password) { |
|
| 146 | + | return Redirect::to("/login?error=Invalid+password").into_response(); |
|
| 147 | + | } |
|
| 148 | + | ||
| 149 | + | let token = auth::generate_session_token(); |
|
| 150 | + | ||
| 151 | + | // Session expires in 7 days |
|
| 152 | + | // We need to compute a datetime 7 days from now |
|
| 153 | + | let expires_at = { |
|
| 154 | + | use std::time::{SystemTime, UNIX_EPOCH}; |
|
| 155 | + | let secs = SystemTime::now() |
|
| 156 | + | .duration_since(UNIX_EPOCH) |
|
| 157 | + | .unwrap() |
|
| 158 | + | .as_secs() |
|
| 159 | + | + 7 * 24 * 3600; |
|
| 160 | + | let days = secs / 86400; |
|
| 161 | + | let tod = secs % 86400; |
|
| 162 | + | let (y, m, d) = days_to_ymd(days as i64); |
|
| 163 | + | format!( |
|
| 164 | + | "{:04}-{:02}-{:02} {:02}:{:02}:{:02}", |
|
| 165 | + | y, |
|
| 166 | + | m, |
|
| 167 | + | d, |
|
| 168 | + | tod / 3600, |
|
| 169 | + | (tod % 3600) / 60, |
|
| 170 | + | tod % 60 |
|
| 171 | + | ) |
|
| 172 | + | }; |
|
| 173 | + | ||
| 174 | + | if let Err(e) = db::insert_session(&state.db, &token, &expires_at) { |
|
| 175 | + | tracing::error!("Failed to create session: {}", e); |
|
| 176 | + | return Redirect::to("/login?error=Server+error").into_response(); |
|
| 177 | + | } |
|
| 178 | + | ||
| 179 | + | let cookie = auth::build_session_cookie(&token, state.cookie_secure); |
|
| 180 | + | let mut resp = Redirect::to("/").into_response(); |
|
| 181 | + | resp.headers_mut().insert( |
|
| 182 | + | axum::http::header::SET_COOKIE, |
|
| 183 | + | HeaderValue::from_str(&cookie).unwrap(), |
|
| 184 | + | ); |
|
| 185 | + | resp |
|
| 186 | + | } |
|
| 187 | + | ||
| 188 | + | async fn get_logout(State(state): State<Arc<AppState>>, headers: axum::http::HeaderMap) -> Response { |
|
| 189 | + | if let Some(cookie_header) = headers.get("cookie").and_then(|v| v.to_str().ok()) { |
|
| 190 | + | for part in cookie_header.split(';') { |
|
| 191 | + | let part = part.trim(); |
|
| 192 | + | if let Some(val) = part.strip_prefix("session=") { |
|
| 193 | + | let val = val.trim(); |
|
| 194 | + | if !val.is_empty() { |
|
| 195 | + | let _ = db::delete_session(&state.db, val); |
|
| 196 | + | } |
|
| 197 | + | } |
|
| 198 | + | } |
|
| 199 | + | } |
|
| 200 | + | ||
| 201 | + | let cookie = auth::clear_session_cookie(); |
|
| 202 | + | let mut resp = Redirect::to("/login").into_response(); |
|
| 203 | + | resp.headers_mut().insert( |
|
| 204 | + | axum::http::header::SET_COOKIE, |
|
| 205 | + | HeaderValue::from_str(&cookie).unwrap(), |
|
| 206 | + | ); |
|
| 207 | + | resp |
|
| 208 | + | } |
|
| 209 | + | ||
| 210 | + | // --- Note handlers --- |
|
| 211 | + | ||
| 212 | + | async fn get_index( |
|
| 213 | + | _session: auth::AuthSession, |
|
| 214 | + | State(state): State<Arc<AppState>>, |
|
| 215 | + | ) -> Response { |
|
| 216 | + | match db::get_all_notes(&state.db) { |
|
| 217 | + | Ok(notes) => WebTemplate(IndexTemplate { notes }).into_response(), |
|
| 218 | + | Err(e) => { |
|
| 219 | + | tracing::error!("Failed to list notes: {}", e); |
|
| 220 | + | (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response() |
|
| 221 | + | } |
|
| 222 | + | } |
|
| 223 | + | } |
|
| 224 | + | ||
| 225 | + | async fn get_new_note( |
|
| 226 | + | _session: auth::AuthSession, |
|
| 227 | + | Query(q): Query<FlashQuery>, |
|
| 228 | + | ) -> Response { |
|
| 229 | + | WebTemplate(NewTemplate { error: q.error }).into_response() |
|
| 230 | + | } |
|
| 231 | + | ||
| 232 | + | async fn post_create_note( |
|
| 233 | + | _session: auth::AuthSession, |
|
| 234 | + | State(state): State<Arc<AppState>>, |
|
| 235 | + | Form(form): Form<NoteForm>, |
|
| 236 | + | ) -> Response { |
|
| 237 | + | let title = form.title.trim(); |
|
| 238 | + | if title.is_empty() { |
|
| 239 | + | return Redirect::to("/notes/new?error=Title+is+required").into_response(); |
|
| 240 | + | } |
|
| 241 | + | ||
| 242 | + | match db::create_note(&state.db, title, &form.content) { |
|
| 243 | + | Ok(note) => Redirect::to(&format!("/notes/{}", note.short_id)).into_response(), |
|
| 244 | + | Err(e) => { |
|
| 245 | + | tracing::error!("Failed to create note: {}", e); |
|
| 246 | + | Redirect::to("/notes/new?error=Failed+to+create+note").into_response() |
|
| 247 | + | } |
|
| 248 | + | } |
|
| 249 | + | } |
|
| 250 | + | ||
| 251 | + | fn render_markdown(content: &str) -> String { |
|
| 252 | + | let mut options = Options::empty(); |
|
| 253 | + | options.insert(Options::ENABLE_STRIKETHROUGH); |
|
| 254 | + | options.insert(Options::ENABLE_TABLES); |
|
| 255 | + | options.insert(Options::ENABLE_TASKLISTS); |
|
| 256 | + | let parser = Parser::new_ext(content, options); |
|
| 257 | + | let mut html_output = String::new(); |
|
| 258 | + | html::push_html(&mut html_output, parser); |
|
| 259 | + | html_output |
|
| 260 | + | } |
|
| 261 | + | ||
| 262 | + | async fn get_view_note( |
|
| 263 | + | _session: auth::AuthSession, |
|
| 264 | + | State(state): State<Arc<AppState>>, |
|
| 265 | + | Path(short_id): Path<String>, |
|
| 266 | + | ) -> Response { |
|
| 267 | + | match db::get_note_by_short_id(&state.db, &short_id) { |
|
| 268 | + | Ok(Some(note)) => { |
|
| 269 | + | let rendered_content = render_markdown(¬e.content); |
|
| 270 | + | WebTemplate(ViewTemplate { |
|
| 271 | + | note, |
|
| 272 | + | rendered_content, |
|
| 273 | + | }) |
|
| 274 | + | .into_response() |
|
| 275 | + | } |
|
| 276 | + | Ok(None) => (StatusCode::NOT_FOUND, Html("Note not found".to_string())).into_response(), |
|
| 277 | + | Err(e) => { |
|
| 278 | + | tracing::error!("Failed to get note: {}", e); |
|
| 279 | + | (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response() |
|
| 280 | + | } |
|
| 281 | + | } |
|
| 282 | + | } |
|
| 283 | + | ||
| 284 | + | async fn get_edit_note( |
|
| 285 | + | _session: auth::AuthSession, |
|
| 286 | + | State(state): State<Arc<AppState>>, |
|
| 287 | + | Path(short_id): Path<String>, |
|
| 288 | + | Query(q): Query<FlashQuery>, |
|
| 289 | + | ) -> Response { |
|
| 290 | + | match db::get_note_by_short_id(&state.db, &short_id) { |
|
| 291 | + | Ok(Some(note)) => WebTemplate(EditTemplate { |
|
| 292 | + | note, |
|
| 293 | + | error: q.error, |
|
| 294 | + | }) |
|
| 295 | + | .into_response(), |
|
| 296 | + | Ok(None) => (StatusCode::NOT_FOUND, Html("Note not found".to_string())).into_response(), |
|
| 297 | + | Err(e) => { |
|
| 298 | + | tracing::error!("Failed to get note: {}", e); |
|
| 299 | + | (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response() |
|
| 300 | + | } |
|
| 301 | + | } |
|
| 302 | + | } |
|
| 303 | + | ||
| 304 | + | async fn post_update_note( |
|
| 305 | + | _session: auth::AuthSession, |
|
| 306 | + | State(state): State<Arc<AppState>>, |
|
| 307 | + | Path(short_id): Path<String>, |
|
| 308 | + | Form(form): Form<NoteForm>, |
|
| 309 | + | ) -> Response { |
|
| 310 | + | let title = form.title.trim(); |
|
| 311 | + | if title.is_empty() { |
|
| 312 | + | return Redirect::to(&format!("/notes/{}/edit?error=Title+is+required", short_id)) |
|
| 313 | + | .into_response(); |
|
| 314 | + | } |
|
| 315 | + | ||
| 316 | + | match db::update_note_by_short_id(&state.db, &short_id, title, &form.content) { |
|
| 317 | + | Ok(Some(_)) => Redirect::to(&format!("/notes/{}", short_id)).into_response(), |
|
| 318 | + | Ok(None) => (StatusCode::NOT_FOUND, Html("Note not found".to_string())).into_response(), |
|
| 319 | + | Err(e) => { |
|
| 320 | + | tracing::error!("Failed to update note: {}", e); |
|
| 321 | + | Redirect::to(&format!( |
|
| 322 | + | "/notes/{}/edit?error=Failed+to+update+note", |
|
| 323 | + | short_id |
|
| 324 | + | )) |
|
| 325 | + | .into_response() |
|
| 326 | + | } |
|
| 327 | + | } |
|
| 328 | + | } |
|
| 329 | + | ||
| 330 | + | async fn post_delete_note( |
|
| 331 | + | _session: auth::AuthSession, |
|
| 332 | + | State(state): State<Arc<AppState>>, |
|
| 333 | + | Path(short_id): Path<String>, |
|
| 334 | + | ) -> Response { |
|
| 335 | + | match db::delete_note_by_short_id(&state.db, &short_id) { |
|
| 336 | + | Ok(_) => Redirect::to("/").into_response(), |
|
| 337 | + | Err(e) => { |
|
| 338 | + | tracing::error!("Failed to delete note: {}", e); |
|
| 339 | + | Redirect::to("/").into_response() |
|
| 340 | + | } |
|
| 341 | + | } |
|
| 342 | + | } |
|
| 343 | + | ||
| 344 | + | // --- Date helper (same algorithm as auth.rs) --- |
|
| 345 | + | ||
| 346 | + | fn days_to_ymd(mut days: i64) -> (i64, i64, i64) { |
|
| 347 | + | days += 719468; |
|
| 348 | + | let era = if days >= 0 { days } else { days - 146096 } / 146097; |
|
| 349 | + | let doe = (days - era * 146097) as u32; |
|
| 350 | + | let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; |
|
| 351 | + | let y = yoe as i64 + era * 400; |
|
| 352 | + | let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); |
|
| 353 | + | let mp = (5 * doy + 2) / 153; |
|
| 354 | + | let d = doy - (153 * mp + 2) / 5 + 1; |
|
| 355 | + | let m = if mp < 10 { mp + 3 } else { mp - 9 }; |
|
| 356 | + | let y = if m <= 2 { y + 1 } else { y }; |
|
| 357 | + | (y, m as i64, d as i64) |
|
| 358 | + | } |
|
| 359 | + | ||
| 360 | + | // --- Router --- |
|
| 361 | + | ||
| 362 | + | pub async fn run(host: String, port: u16) { |
|
| 363 | + | dotenvy::dotenv().ok(); |
|
| 364 | + | ||
| 365 | + | let db = db::init_db(); |
|
| 366 | + | ||
| 367 | + | // Prune expired sessions on startup |
|
| 368 | + | if let Err(e) = db::prune_expired_sessions(&db) { |
|
| 369 | + | tracing::warn!("Failed to prune sessions: {}", e); |
|
| 370 | + | } |
|
| 371 | + | ||
| 372 | + | let app_password = std::env::var("JOTTS_PASSWORD").unwrap_or_else(|_| { |
|
| 373 | + | tracing::warn!("JOTTS_PASSWORD not set, using default 'changeme'"); |
|
| 374 | + | "changeme".to_string() |
|
| 375 | + | }); |
|
| 376 | + | ||
| 377 | + | let cookie_secure = std::env::var("COOKIE_SECURE") |
|
| 378 | + | .map(|v| v == "true") |
|
| 379 | + | .unwrap_or(false); |
|
| 380 | + | ||
| 381 | + | let state = Arc::new(AppState { |
|
| 382 | + | db, |
|
| 383 | + | app_password, |
|
| 384 | + | cookie_secure, |
|
| 385 | + | }); |
|
| 386 | + | ||
| 387 | + | let app = Router::new() |
|
| 388 | + | // Public routes |
|
| 389 | + | .route("/login", get(get_login).post(post_login)) |
|
| 390 | + | .route("/logout", get(get_logout)) |
|
| 391 | + | // Protected routes |
|
| 392 | + | .route("/", get(get_index)) |
|
| 393 | + | .route("/notes/new", get(get_new_note)) |
|
| 394 | + | .route("/notes", post(post_create_note)) |
|
| 395 | + | .route("/notes/{short_id}", get(get_view_note)) |
|
| 396 | + | .route("/notes/{short_id}/edit", get(get_edit_note)) |
|
| 397 | + | .route("/notes/{short_id}", post(post_update_note)) |
|
| 398 | + | .route("/notes/{short_id}/delete", post(post_delete_note)) |
|
| 399 | + | // Static assets |
|
| 400 | + | .route("/assets/{*path}", get(serve_asset)) |
|
| 401 | + | .route("/static/{*path}", get(serve_static)) |
|
| 402 | + | .with_state(state); |
|
| 403 | + | ||
| 404 | + | let addr = format!("{}:{}", host, port); |
|
| 405 | + | tracing::info!("Listening on http://{}", addr); |
|
| 406 | + | ||
| 407 | + | let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); |
|
| 408 | + | axum::serve(listener, app).await.unwrap(); |
|
| 409 | + | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | + | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | + | @font-face { |
|
| 2 | + | font-family: "Commit Mono"; |
|
| 3 | + | src: url("/assets/fonts/CommitMono-400-Regular.otf") format("opentype"); |
|
| 4 | + | font-weight: 400; |
|
| 5 | + | font-style: normal; |
|
| 6 | + | } |
|
| 7 | + | ||
| 8 | + | @font-face { |
|
| 9 | + | font-family: "Commit Mono"; |
|
| 10 | + | src: url("/assets/fonts/CommitMono-700-Regular.otf") format("opentype"); |
|
| 11 | + | font-weight: 700; |
|
| 12 | + | font-style: normal; |
|
| 13 | + | } |
|
| 14 | + | ||
| 15 | + | * { |
|
| 16 | + | padding: 0; |
|
| 17 | + | margin: 0; |
|
| 18 | + | box-sizing: border-box; |
|
| 19 | + | font-family: "Commit Mono", monospace, sans-serif; |
|
| 20 | + | scrollbar-width: none; |
|
| 21 | + | -ms-overflow-style: none; |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | html { |
|
| 25 | + | background: #121113; |
|
| 26 | + | color: #ffffff; |
|
| 27 | + | font-size: 14px; |
|
| 28 | + | line-height: 1.6; |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | html::-webkit-scrollbar { |
|
| 32 | + | display: none; |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | body { |
|
| 36 | + | display: flex; |
|
| 37 | + | flex-direction: column; |
|
| 38 | + | justify-content: start; |
|
| 39 | + | align-items: start; |
|
| 40 | + | gap: 1.5rem; |
|
| 41 | + | min-height: 100vh; |
|
| 42 | + | max-width: 700px; |
|
| 43 | + | margin: auto; |
|
| 44 | + | padding: 0 1rem; |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | @media (max-width: 480px) { |
|
| 48 | + | body { |
|
| 49 | + | padding: 1rem; |
|
| 50 | + | gap: 1rem; |
|
| 51 | + | } |
|
| 52 | + | } |
|
| 53 | + | ||
| 54 | + | a { |
|
| 55 | + | color: #ffffff; |
|
| 56 | + | text-decoration: none; |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | a:hover { |
|
| 60 | + | opacity: 0.7; |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | /* Header */ |
|
| 64 | + | ||
| 65 | + | .header { |
|
| 66 | + | display: flex; |
|
| 67 | + | flex-direction: column; |
|
| 68 | + | gap: 0.5rem; |
|
| 69 | + | width: 100%; |
|
| 70 | + | margin-top: 2rem; |
|
| 71 | + | border-bottom: 1px solid #333; |
|
| 72 | + | padding-bottom: 1rem; |
|
| 73 | + | } |
|
| 74 | + | ||
| 75 | + | .logo { |
|
| 76 | + | font-size: 28px; |
|
| 77 | + | font-weight: 700; |
|
| 78 | + | text-decoration: none; |
|
| 79 | + | text-transform: uppercase; |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | .links { |
|
| 83 | + | display: flex; |
|
| 84 | + | align-items: center; |
|
| 85 | + | gap: 0.75rem; |
|
| 86 | + | font-size: 12px; |
|
| 87 | + | } |
|
| 88 | + | ||
| 89 | + | /* Main content */ |
|
| 90 | + | ||
| 91 | + | main { |
|
| 92 | + | width: 100%; |
|
| 93 | + | display: flex; |
|
| 94 | + | flex-direction: column; |
|
| 95 | + | gap: 1rem; |
|
| 96 | + | } |
|
| 97 | + | ||
| 98 | + | /* Forms */ |
|
| 99 | + | ||
| 100 | + | .form { |
|
| 101 | + | display: flex; |
|
| 102 | + | flex-direction: column; |
|
| 103 | + | gap: 0.5rem; |
|
| 104 | + | width: 100%; |
|
| 105 | + | } |
|
| 106 | + | ||
| 107 | + | label { |
|
| 108 | + | font-size: 12px; |
|
| 109 | + | opacity: 0.7; |
|
| 110 | + | } |
|
| 111 | + | ||
| 112 | + | input, textarea { |
|
| 113 | + | background: #121113; |
|
| 114 | + | color: #ffffff; |
|
| 115 | + | border: 1px solid white; |
|
| 116 | + | padding: 0.4rem 0.75rem; |
|
| 117 | + | font-size: 16px; |
|
| 118 | + | width: 100%; |
|
| 119 | + | border-radius: 0; |
|
| 120 | + | } |
|
| 121 | + | ||
| 122 | + | textarea { |
|
| 123 | + | min-height: 400px; |
|
| 124 | + | resize: vertical; |
|
| 125 | + | } |
|
| 126 | + | ||
| 127 | + | button { |
|
| 128 | + | background: #121113; |
|
| 129 | + | color: #ffffff; |
|
| 130 | + | padding: 0.4rem 0.75rem; |
|
| 131 | + | border: 1px solid white; |
|
| 132 | + | cursor: pointer; |
|
| 133 | + | width: fit-content; |
|
| 134 | + | font-size: 14px; |
|
| 135 | + | border-radius: 0; |
|
| 136 | + | } |
|
| 137 | + | ||
| 138 | + | button:hover { |
|
| 139 | + | opacity: 0.7; |
|
| 140 | + | } |
|
| 141 | + | ||
| 142 | + | /* Error */ |
|
| 143 | + | ||
| 144 | + | .error { |
|
| 145 | + | color: #ffffff; |
|
| 146 | + | border-left: 2px solid #ffffff; |
|
| 147 | + | padding-left: 0.5rem; |
|
| 148 | + | font-size: 13px; |
|
| 149 | + | opacity: 0.8; |
|
| 150 | + | } |
|
| 151 | + | ||
| 152 | + | /* Note list */ |
|
| 153 | + | ||
| 154 | + | .note-list { |
|
| 155 | + | display: flex; |
|
| 156 | + | flex-direction: column; |
|
| 157 | + | width: 100%; |
|
| 158 | + | } |
|
| 159 | + | ||
| 160 | + | .note-item { |
|
| 161 | + | display: flex; |
|
| 162 | + | justify-content: space-between; |
|
| 163 | + | align-items: center; |
|
| 164 | + | padding: 8px 0; |
|
| 165 | + | border-bottom: 1px solid #333; |
|
| 166 | + | text-decoration: none; |
|
| 167 | + | } |
|
| 168 | + | ||
| 169 | + | .note-item:hover { |
|
| 170 | + | opacity: 0.7; |
|
| 171 | + | } |
|
| 172 | + | ||
| 173 | + | .note-title { |
|
| 174 | + | font-size: 16px; |
|
| 175 | + | } |
|
| 176 | + | ||
| 177 | + | .note-date { |
|
| 178 | + | font-size: 12px; |
|
| 179 | + | opacity: 0.5; |
|
| 180 | + | } |
|
| 181 | + | ||
| 182 | + | .empty { |
|
| 183 | + | opacity: 0.5; |
|
| 184 | + | font-size: 12px; |
|
| 185 | + | } |
|
| 186 | + | ||
| 187 | + | /* Note view */ |
|
| 188 | + | ||
| 189 | + | .note-header { |
|
| 190 | + | display: flex; |
|
| 191 | + | flex-direction: column; |
|
| 192 | + | gap: 0.25rem; |
|
| 193 | + | } |
|
| 194 | + | ||
| 195 | + | .note-header h1 { |
|
| 196 | + | font-size: 24px; |
|
| 197 | + | font-weight: 700; |
|
| 198 | + | letter-spacing: -0.5px; |
|
| 199 | + | } |
|
| 200 | + | ||
| 201 | + | .note-actions { |
|
| 202 | + | display: flex; |
|
| 203 | + | gap: 1.5rem; |
|
| 204 | + | font-size: 12px; |
|
| 205 | + | } |
|
| 206 | + | ||
| 207 | + | .inline-form { |
|
| 208 | + | display: inline; |
|
| 209 | + | } |
|
| 210 | + | ||
| 211 | + | .link-button { |
|
| 212 | + | background: none; |
|
| 213 | + | border: none; |
|
| 214 | + | color: #ffffff; |
|
| 215 | + | cursor: pointer; |
|
| 216 | + | font-size: 12px; |
|
| 217 | + | padding: 0; |
|
| 218 | + | } |
|
| 219 | + | ||
| 220 | + | .link-button:hover { |
|
| 221 | + | opacity: 0.7; |
|
| 222 | + | } |
|
| 223 | + | ||
| 224 | + | /* Markdown rendered content */ |
|
| 225 | + | ||
| 226 | + | .markdown-body { |
|
| 227 | + | width: 100%; |
|
| 228 | + | line-height: 1.6; |
|
| 229 | + | } |
|
| 230 | + | ||
| 231 | + | .markdown-body h1, |
|
| 232 | + | .markdown-body h2, |
|
| 233 | + | .markdown-body h3, |
|
| 234 | + | .markdown-body h4, |
|
| 235 | + | .markdown-body h5, |
|
| 236 | + | .markdown-body h6 { |
|
| 237 | + | margin-top: 1.5rem; |
|
| 238 | + | margin-bottom: 0.5rem; |
|
| 239 | + | font-weight: 700; |
|
| 240 | + | } |
|
| 241 | + | ||
| 242 | + | .markdown-body h1 { font-size: 18px; } |
|
| 243 | + | .markdown-body h2 { font-size: 16px; } |
|
| 244 | + | .markdown-body h3 { font-size: 15px; } |
|
| 245 | + | .markdown-body h4, |
|
| 246 | + | .markdown-body h5, |
|
| 247 | + | .markdown-body h6 { font-size: 14px; } |
|
| 248 | + | ||
| 249 | + | .markdown-body p { |
|
| 250 | + | margin-bottom: 0.75rem; |
|
| 251 | + | } |
|
| 252 | + | ||
| 253 | + | .markdown-body ul, |
|
| 254 | + | .markdown-body ol { |
|
| 255 | + | margin-left: 1.5rem; |
|
| 256 | + | margin-bottom: 0.75rem; |
|
| 257 | + | } |
|
| 258 | + | ||
| 259 | + | .markdown-body li { |
|
| 260 | + | margin-bottom: 0.25rem; |
|
| 261 | + | } |
|
| 262 | + | ||
| 263 | + | .markdown-body code { |
|
| 264 | + | background: #1e1c1f; |
|
| 265 | + | padding: 2px 4px; |
|
| 266 | + | font-size: 13px; |
|
| 267 | + | } |
|
| 268 | + | ||
| 269 | + | .markdown-body pre { |
|
| 270 | + | background: #1e1c1f; |
|
| 271 | + | padding: 12px; |
|
| 272 | + | overflow-x: auto; |
|
| 273 | + | margin-bottom: 0.75rem; |
|
| 274 | + | border: 1px solid #333; |
|
| 275 | + | } |
|
| 276 | + | ||
| 277 | + | .markdown-body pre code { |
|
| 278 | + | background: none; |
|
| 279 | + | padding: 0; |
|
| 280 | + | } |
|
| 281 | + | ||
| 282 | + | .markdown-body blockquote { |
|
| 283 | + | border-left: 2px solid #555; |
|
| 284 | + | padding-left: 12px; |
|
| 285 | + | opacity: 0.7; |
|
| 286 | + | margin-bottom: 0.75rem; |
|
| 287 | + | } |
|
| 288 | + | ||
| 289 | + | .markdown-body table { |
|
| 290 | + | width: 100%; |
|
| 291 | + | border-collapse: collapse; |
|
| 292 | + | margin-bottom: 0.75rem; |
|
| 293 | + | } |
|
| 294 | + | ||
| 295 | + | .markdown-body th, |
|
| 296 | + | .markdown-body td { |
|
| 297 | + | border: 1px solid #333; |
|
| 298 | + | padding: 6px; |
|
| 299 | + | text-align: left; |
|
| 300 | + | } |
|
| 301 | + | ||
| 302 | + | .markdown-body th { |
|
| 303 | + | font-weight: 700; |
|
| 304 | + | text-transform: uppercase; |
|
| 305 | + | opacity: 0.5; |
|
| 306 | + | font-size: 12px; |
|
| 307 | + | } |
|
| 308 | + | ||
| 309 | + | .markdown-body hr { |
|
| 310 | + | border: none; |
|
| 311 | + | border-top: 1px solid #333; |
|
| 312 | + | margin: 1rem 0; |
|
| 313 | + | } |
|
| 314 | + | ||
| 315 | + | .markdown-body a { |
|
| 316 | + | text-decoration: underline; |
|
| 317 | + | } |
|
| 318 | + | ||
| 319 | + | .markdown-body img { |
|
| 320 | + | max-width: 100%; |
|
| 321 | + | } |
|
| 322 | + | ||
| 323 | + | .markdown-body li:has(> input[type="checkbox"]) { |
|
| 324 | + | list-style: none; |
|
| 325 | + | margin-left: -1.5rem; |
|
| 326 | + | } |
|
| 327 | + | ||
| 328 | + | .markdown-body input[type="checkbox"] { |
|
| 329 | + | -webkit-appearance: none; |
|
| 330 | + | appearance: none; |
|
| 331 | + | width: 14px; |
|
| 332 | + | height: 14px; |
|
| 333 | + | background: transparent; |
|
| 334 | + | border: 1px solid white; |
|
| 335 | + | border-radius: 0; |
|
| 336 | + | padding: 0; |
|
| 337 | + | margin-right: 6px; |
|
| 338 | + | vertical-align: middle; |
|
| 339 | + | position: relative; |
|
| 340 | + | top: -1px; |
|
| 341 | + | cursor: pointer; |
|
| 342 | + | } |
|
| 343 | + | ||
| 344 | + | .markdown-body input[type="checkbox"]:checked::after { |
|
| 345 | + | content: '✔︎'; |
|
| 346 | + | position: absolute; |
|
| 347 | + | top: 50%; |
|
| 348 | + | left: 50%; |
|
| 349 | + | transform: translate(-50%, -50%); |
|
| 350 | + | font-size: 12px; |
|
| 351 | + | color: white; |
|
| 352 | + | line-height: 1; |
|
| 353 | + | } |
|
| 354 | + | } |
| 1 | + | <!DOCTYPE html> |
|
| 2 | + | <html lang="en"> |
|
| 3 | + | <head> |
|
| 4 | + | <meta charset="UTF-8"> |
|
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
| 6 | + | <title>{% block title %}Jotts{% endblock %}</title> |
|
| 7 | + | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 8 | + | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 9 | + | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 10 | + | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 11 | + | <link rel="icon" href="/static/favicon.ico"> |
|
| 12 | + | <meta property="og:title" content="Jotts"> |
|
| 13 | + | <meta property="og:image" content="/static/og.png"> |
|
| 14 | + | <meta property="og:type" content="website"> |
|
| 15 | + | <meta name="theme-color" content="#121113" /> |
|
| 16 | + | <link rel="stylesheet" href="/static/styles.css"> |
|
| 17 | + | </head> |
|
| 18 | + | <body> |
|
| 19 | + | <header class="header"> |
|
| 20 | + | <a href="/" class="logo">jotts</a> |
|
| 21 | + | <nav class="links"> |
|
| 22 | + | <a href="/notes/new">new</a> |
|
| 23 | + | </nav> |
|
| 24 | + | </header> |
|
| 25 | + | <main> |
|
| 26 | + | {% block content %}{% endblock %} |
|
| 27 | + | </main> |
|
| 28 | + | <script> |
|
| 29 | + | document.querySelectorAll("time.note-date").forEach(el => { |
|
| 30 | + | const d = new Date(el.getAttribute("datetime")); |
|
| 31 | + | if (!isNaN(d)) { |
|
| 32 | + | el.textContent = d.toLocaleString(); |
|
| 33 | + | } |
|
| 34 | + | }); |
|
| 35 | + | </script> |
|
| 36 | + | </body> |
|
| 37 | + | </html> |
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}Jotts — {{ note.title }}{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | {% if let Some(error) = error %} |
|
| 5 | + | <p class="error">{{ error }}</p> |
|
| 6 | + | {% endif %} |
|
| 7 | + | <form method="POST" action="/notes/{{ note.short_id }}" class="form"> |
|
| 8 | + | <label for="title">title</label> |
|
| 9 | + | <input type="text" id="title" name="title" value="{{ note.title }}" required> |
|
| 10 | + | <label for="content">content</label> |
|
| 11 | + | <textarea id="content" name="content">{{ note.content }}</textarea> |
|
| 12 | + | <button type="submit">save</button> |
|
| 13 | + | </form> |
|
| 14 | + | {% endblock %} |
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}Jotts{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | {% if notes.is_empty() %} |
|
| 5 | + | <p class="empty">no notes yet</p> |
|
| 6 | + | {% endif %} |
|
| 7 | + | <div class="note-list"> |
|
| 8 | + | {% for note in notes %} |
|
| 9 | + | <a href="/notes/{{ note.short_id }}" class="note-item"> |
|
| 10 | + | <span class="note-title">{{ note.title }}</span> |
|
| 11 | + | <time class="note-date" datetime="{{ note.updated_at }}Z">{{ note.updated_at }}</time> |
|
| 12 | + | </a> |
|
| 13 | + | {% endfor %} |
|
| 14 | + | </div> |
|
| 15 | + | {% endblock %} |
| 1 | + | <!DOCTYPE html> |
|
| 2 | + | <html lang="en"> |
|
| 3 | + | <head> |
|
| 4 | + | <meta charset="UTF-8"> |
|
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
| 6 | + | <title>Jotts</title> |
|
| 7 | + | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 8 | + | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 9 | + | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 10 | + | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 11 | + | <link rel="icon" href="/static/favicon.ico"> |
|
| 12 | + | <meta property="og:title" content="Jotts"> |
|
| 13 | + | <meta property="og:image" content="/static/og.png"> |
|
| 14 | + | <meta property="og:type" content="website"> |
|
| 15 | + | <meta name="theme-color" content="#121113" /> |
|
| 16 | + | <link rel="stylesheet" href="/static/styles.css"> |
|
| 17 | + | </head> |
|
| 18 | + | <body> |
|
| 19 | + | <header class="header"> |
|
| 20 | + | <span class="logo">JOTTS</span> |
|
| 21 | + | </header> |
|
| 22 | + | <main> |
|
| 23 | + | {% if let Some(error) = error %} |
|
| 24 | + | <p class="error">{{ error }}</p> |
|
| 25 | + | {% endif %} |
|
| 26 | + | <form method="POST" action="/login" class="form"> |
|
| 27 | + | <label for="password">password</label> |
|
| 28 | + | <input type="password" id="password" name="password" autofocus required> |
|
| 29 | + | <button type="submit">login</button> |
|
| 30 | + | </form> |
|
| 31 | + | </main> |
|
| 32 | + | </body> |
|
| 33 | + | </html> |
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}Jotts{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | {% if let Some(error) = error %} |
|
| 5 | + | <p class="error">{{ error }}</p> |
|
| 6 | + | {% endif %} |
|
| 7 | + | <form method="POST" action="/notes" class="form"> |
|
| 8 | + | <label for="title">title</label> |
|
| 9 | + | <input type="text" id="title" name="title" autofocus required> |
|
| 10 | + | <label for="content">content</label> |
|
| 11 | + | <textarea id="content" name="content" placeholder="write markdown here..."></textarea> |
|
| 12 | + | <button type="submit">save</button> |
|
| 13 | + | </form> |
|
| 14 | + | {% endblock %} |
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}Jotts — {{ note.title }}{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | <div class="note-header"> |
|
| 5 | + | <h1>{{ note.title }}</h1> |
|
| 6 | + | <time class="note-date" datetime="{{ note.updated_at }}Z">{{ note.updated_at }}</time> |
|
| 7 | + | </div> |
|
| 8 | + | <div class="note-actions"> |
|
| 9 | + | <a href="/notes/{{ note.short_id }}/edit">edit</a> |
|
| 10 | + | <form method="POST" action="/notes/{{ note.short_id }}/delete" class="inline-form"> |
|
| 11 | + | <button type="submit" class="link-button" onclick="return confirm('delete this note?')">delete</button> |
|
| 12 | + | </form> |
|
| 13 | + | </div> |
|
| 14 | + | <article class="markdown-body"> |
|
| 15 | + | {{ rendered_content|safe }} |
|
| 16 | + | </article> |
|
| 17 | + | {% endblock %} |
| 1 | + | PORT=3000 |
| 1 | + | [package] |
|
| 2 | + | name = "og" |
|
| 3 | + | version = "0.1.0" |
|
| 4 | + | edition = "2024" |
|
| 5 | + | ||
| 6 | + | [dependencies] |
|
| 7 | + | axum = { workspace = true } |
|
| 8 | + | tokio = { workspace = true } |
|
| 9 | + | serde = { workspace = true } |
|
| 10 | + | serde_json = { workspace = true } |
|
| 11 | + | tower-http = { workspace = true, features = ["cors"] } |
|
| 12 | + | rust-embed = { workspace = true, features = ["mime-guess"] } |
|
| 13 | + | dotenvy = { workspace = true } |
|
| 14 | + | tracing = { workspace = true } |
|
| 15 | + | tracing-subscriber = { workspace = true } |
|
| 16 | + | askama = "0.13" |
|
| 17 | + | reqwest = { version = "0.12", features = ["rustls-tls"], default-features = false } |
|
| 18 | + | scraper = "0.22" |
|
| 19 | + | url = "2" |
| 1 | + | # Build from repo root: docker build -t og -f apps/og/Dockerfile . |
|
| 2 | + | FROM rust:1-slim-bookworm AS builder |
|
| 3 | + | WORKDIR /app |
|
| 4 | + | ||
| 5 | + | # Copy workspace manifests |
|
| 6 | + | COPY Cargo.toml Cargo.lock ./ |
|
| 7 | + | COPY crates/auth/Cargo.toml crates/auth/ |
|
| 8 | + | COPY apps/sipp/Cargo.toml apps/sipp/ |
|
| 9 | + | COPY apps/feeds/Cargo.toml apps/feeds/ |
|
| 10 | + | COPY apps/parcels/Cargo.toml apps/parcels/ |
|
| 11 | + | COPY apps/jotts/Cargo.toml apps/jotts/ |
|
| 12 | + | COPY apps/og/Cargo.toml apps/og/ |
|
| 13 | + | COPY apps/shrink/Cargo.toml apps/shrink/ |
|
| 14 | + | ||
| 15 | + | # Create stubs for dependency caching |
|
| 16 | + | RUN mkdir -p crates/auth/src && echo '' > crates/auth/src/lib.rs \ |
|
| 17 | + | && for app in sipp feeds parcels jotts og shrink; do \ |
|
| 18 | + | mkdir -p apps/$app/src && echo 'fn main() {}' > apps/$app/src/main.rs; \ |
|
| 19 | + | done |
|
| 20 | + | ||
| 21 | + | RUN cargo build --release -p og |
|
| 22 | + | ||
| 23 | + | # Copy real source |
|
| 24 | + | COPY apps/og/src apps/og/src |
|
| 25 | + | COPY apps/og/templates apps/og/templates |
|
| 26 | + | COPY apps/og/static apps/og/static |
|
| 27 | + | ||
| 28 | + | RUN touch apps/og/src/*.rs && cargo build --release -p og |
|
| 29 | + | ||
| 30 | + | FROM debian:bookworm-slim |
|
| 31 | + | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* |
|
| 32 | + | WORKDIR /app |
|
| 33 | + | COPY --from=builder /app/target/release/og . |
|
| 34 | + | EXPOSE 3000 |
|
| 35 | + | CMD ["./og"] |
| 1 | + | MIT License |
|
| 2 | + | ||
| 3 | + | Copyright (c) 2026 Steve Simkins |
|
| 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 | + |
| 1 | + | # OG |
|
| 2 | + | ||
| 3 | + |  |
|
| 4 | + | ||
| 5 | + | A simple web tool for inspecting Open Graph tags on any URL. |
|
| 6 | + | ||
| 7 | + | ## Running Locally |
|
| 8 | + | ||
| 9 | + | ```bash |
|
| 10 | + | cargo run |
|
| 11 | + | ``` |
|
| 12 | + | ||
| 13 | + | The server starts on `http://localhost:3000` by default. Set the `PORT` env var to change it. |
|
| 14 | + | ||
| 15 | + | ## Docker |
|
| 16 | + | ||
| 17 | + | ```bash |
|
| 18 | + | docker compose up --build |
|
| 19 | + | ``` |
|
| 20 | + | ||
| 21 | + | ## License |
|
| 22 | + | ||
| 23 | + | [MIT](LICENSE) |
| 1 | + | services: |
|
| 2 | + | og: |
|
| 3 | + | build: |
|
| 4 | + | context: ../.. |
|
| 5 | + | dockerfile: apps/og/Dockerfile |
|
| 6 | + | ports: |
|
| 7 | + | - "3000:3000" |
|
| 8 | + | volumes: |
|
| 9 | + | - db_data:/app/data |
|
| 10 | + | environment: |
|
| 11 | + | - PORT=3000 |
|
| 12 | + | ||
| 13 | + | volumes: |
|
| 14 | + | db_data: |
| 1 | + | mod og; |
|
| 2 | + | mod server; |
|
| 3 | + | ||
| 4 | + | #[tokio::main] |
|
| 5 | + | async fn main() { |
|
| 6 | + | dotenvy::dotenv().ok(); |
|
| 7 | + | tracing_subscriber::fmt::init(); |
|
| 8 | + | server::run().await; |
|
| 9 | + | } |
| 1 | + | use scraper::{Html, Selector}; |
|
| 2 | + | use std::collections::HashMap; |
|
| 3 | + | use url::Url; |
|
| 4 | + | ||
| 5 | + | pub struct LinkTag { |
|
| 6 | + | pub rel: String, |
|
| 7 | + | pub href: String, |
|
| 8 | + | pub extra: String, |
|
| 9 | + | } |
|
| 10 | + | ||
| 11 | + | pub struct OgResult { |
|
| 12 | + | pub og_tags: HashMap<String, String>, |
|
| 13 | + | pub favicon: Option<String>, |
|
| 14 | + | pub link_tags: Vec<LinkTag>, |
|
| 15 | + | } |
|
| 16 | + | ||
| 17 | + | pub async fn fetch_og_data(target_url: &str) -> Result<OgResult, String> { |
|
| 18 | + | let parsed_url = Url::parse(target_url).map_err(|e| format!("Invalid URL: {e}"))?; |
|
| 19 | + | ||
| 20 | + | let client = reqwest::Client::builder() |
|
| 21 | + | .timeout(std::time::Duration::from_secs(10)) |
|
| 22 | + | .user_agent("Mozilla/5.0 (compatible; OGPreview/1.0)") |
|
| 23 | + | .build() |
|
| 24 | + | .map_err(|e| format!("Failed to create HTTP client: {e}"))?; |
|
| 25 | + | ||
| 26 | + | let resp = client |
|
| 27 | + | .get(target_url) |
|
| 28 | + | .send() |
|
| 29 | + | .await |
|
| 30 | + | .map_err(|e| format!("Failed to fetch URL: {e}"))?; |
|
| 31 | + | ||
| 32 | + | if !resp.status().is_success() { |
|
| 33 | + | return Err(format!("HTTP error: {}", resp.status())); |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | let content_type = resp |
|
| 37 | + | .headers() |
|
| 38 | + | .get("content-type") |
|
| 39 | + | .and_then(|v| v.to_str().ok()) |
|
| 40 | + | .unwrap_or(""); |
|
| 41 | + | if !content_type.contains("text/html") && !content_type.contains("application/xhtml") { |
|
| 42 | + | return Err(format!("Not an HTML page (Content-Type: {content_type})")); |
|
| 43 | + | } |
|
| 44 | + | ||
| 45 | + | let body = resp |
|
| 46 | + | .text() |
|
| 47 | + | .await |
|
| 48 | + | .map_err(|e| format!("Failed to read response body: {e}"))?; |
|
| 49 | + | ||
| 50 | + | let document = Html::parse_document(&body); |
|
| 51 | + | ||
| 52 | + | // Extract OG tags |
|
| 53 | + | let mut og_tags = HashMap::new(); |
|
| 54 | + | let og_property = Selector::parse(r#"meta[property^="og:"]"#).unwrap(); |
|
| 55 | + | let og_name = Selector::parse(r#"meta[name^="og:"]"#).unwrap(); |
|
| 56 | + | ||
| 57 | + | for el in document.select(&og_property).chain(document.select(&og_name)) { |
|
| 58 | + | let key = el |
|
| 59 | + | .value() |
|
| 60 | + | .attr("property") |
|
| 61 | + | .or_else(|| el.value().attr("name")); |
|
| 62 | + | let value = el.value().attr("content"); |
|
| 63 | + | if let (Some(k), Some(v)) = (key, value) { |
|
| 64 | + | og_tags.entry(k.to_string()).or_insert_with(|| v.to_string()); |
|
| 65 | + | } |
|
| 66 | + | } |
|
| 67 | + | ||
| 68 | + | // Resolve relative og:image URL |
|
| 69 | + | if let Some(image) = og_tags.get("og:image") { |
|
| 70 | + | if let Ok(resolved) = parsed_url.join(image) { |
|
| 71 | + | og_tags.insert("og:image".to_string(), resolved.to_string()); |
|
| 72 | + | } |
|
| 73 | + | } |
|
| 74 | + | ||
| 75 | + | // Extract favicon |
|
| 76 | + | let favicon = extract_favicon(&document, &parsed_url); |
|
| 77 | + | ||
| 78 | + | // Extract head <link> tags |
|
| 79 | + | let link_tags = extract_link_tags(&document, &parsed_url); |
|
| 80 | + | ||
| 81 | + | Ok(OgResult { og_tags, favicon, link_tags }) |
|
| 82 | + | } |
|
| 83 | + | ||
| 84 | + | fn extract_favicon(document: &Html, base_url: &Url) -> Option<String> { |
|
| 85 | + | let selectors = [ |
|
| 86 | + | r#"link[rel="icon"]"#, |
|
| 87 | + | r#"link[rel="shortcut icon"]"#, |
|
| 88 | + | r#"link[rel="apple-touch-icon"]"#, |
|
| 89 | + | ]; |
|
| 90 | + | for sel_str in &selectors { |
|
| 91 | + | if let Ok(sel) = Selector::parse(sel_str) { |
|
| 92 | + | if let Some(el) = document.select(&sel).next() { |
|
| 93 | + | if let Some(href) = el.value().attr("href") { |
|
| 94 | + | if let Ok(resolved) = base_url.join(href) { |
|
| 95 | + | return Some(resolved.to_string()); |
|
| 96 | + | } |
|
| 97 | + | } |
|
| 98 | + | } |
|
| 99 | + | } |
|
| 100 | + | } |
|
| 101 | + | // Fallback to /favicon.ico |
|
| 102 | + | base_url.join("/favicon.ico").ok().map(|u| u.to_string()) |
|
| 103 | + | } |
|
| 104 | + | ||
| 105 | + | fn extract_link_tags(document: &Html, base_url: &Url) -> Vec<LinkTag> { |
|
| 106 | + | let mut link_tags = Vec::new(); |
|
| 107 | + | if let Ok(sel) = Selector::parse("head link") { |
|
| 108 | + | for el in document.select(&sel) { |
|
| 109 | + | let rel = el.value().attr("rel").unwrap_or("").to_string(); |
|
| 110 | + | let href = el |
|
| 111 | + | .value() |
|
| 112 | + | .attr("href") |
|
| 113 | + | .map(|h| base_url.join(h).map(|u| u.to_string()).unwrap_or_else(|_| h.to_string())) |
|
| 114 | + | .unwrap_or_default(); |
|
| 115 | + | let mut extras = Vec::new(); |
|
| 116 | + | for (name, val) in el.value().attrs() { |
|
| 117 | + | if name != "rel" && name != "href" { |
|
| 118 | + | extras.push(format!("{name}=\"{val}\"")); |
|
| 119 | + | } |
|
| 120 | + | } |
|
| 121 | + | link_tags.push(LinkTag { |
|
| 122 | + | rel, |
|
| 123 | + | href, |
|
| 124 | + | extra: extras.join(" "), |
|
| 125 | + | }); |
|
| 126 | + | } |
|
| 127 | + | } |
|
| 128 | + | link_tags |
|
| 129 | + | } |
| 1 | + | use axum::{ |
|
| 2 | + | Router, |
|
| 3 | + | extract::{Form, Path}, |
|
| 4 | + | http::{StatusCode, header}, |
|
| 5 | + | response::{Html, IntoResponse, Response}, |
|
| 6 | + | routing::{get, post}, |
|
| 7 | + | }; |
|
| 8 | + | use askama::Template; |
|
| 9 | + | use rust_embed::Embed; |
|
| 10 | + | use serde::Deserialize; |
|
| 11 | + | ||
| 12 | + | use crate::og; |
|
| 13 | + | ||
| 14 | + | const COMMON_TAGS: &[&str] = &[ |
|
| 15 | + | "og:title", |
|
| 16 | + | "og:description", |
|
| 17 | + | "og:image", |
|
| 18 | + | "og:url", |
|
| 19 | + | "og:type", |
|
| 20 | + | ]; |
|
| 21 | + | ||
| 22 | + | #[derive(Embed)] |
|
| 23 | + | #[folder = "static/"] |
|
| 24 | + | struct StaticAssets; |
|
| 25 | + | ||
| 26 | + | #[derive(Template)] |
|
| 27 | + | #[template(path = "index.html")] |
|
| 28 | + | struct IndexTemplate; |
|
| 29 | + | ||
| 30 | + | #[derive(Template)] |
|
| 31 | + | #[template(path = "results.html")] |
|
| 32 | + | struct ResultsTemplate { |
|
| 33 | + | url: String, |
|
| 34 | + | error: Option<String>, |
|
| 35 | + | og_image: Option<String>, |
|
| 36 | + | favicon: Option<String>, |
|
| 37 | + | found_tags: Vec<(String, String)>, |
|
| 38 | + | missing_tags: Vec<String>, |
|
| 39 | + | link_tags: Vec<(String, String, String)>, |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | async fn get_index() -> impl IntoResponse { |
|
| 43 | + | Html(IndexTemplate.render().unwrap()) |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | #[derive(Deserialize)] |
|
| 47 | + | struct CheckForm { |
|
| 48 | + | url: String, |
|
| 49 | + | } |
|
| 50 | + | ||
| 51 | + | async fn post_check(Form(form): Form<CheckForm>) -> Response { |
|
| 52 | + | let mut url = form.url.trim().to_string(); |
|
| 53 | + | ||
| 54 | + | if url.is_empty() { |
|
| 55 | + | let tmpl = ResultsTemplate { |
|
| 56 | + | url, |
|
| 57 | + | error: Some("Please enter a URL".to_string()), |
|
| 58 | + | og_image: None, |
|
| 59 | + | favicon: None, |
|
| 60 | + | found_tags: Vec::new(), |
|
| 61 | + | missing_tags: Vec::new(), |
|
| 62 | + | link_tags: Vec::new(), |
|
| 63 | + | }; |
|
| 64 | + | return Html(tmpl.render().unwrap()).into_response(); |
|
| 65 | + | } |
|
| 66 | + | ||
| 67 | + | if !url.starts_with("http://") && !url.starts_with("https://") { |
|
| 68 | + | url = format!("https://{url}"); |
|
| 69 | + | } |
|
| 70 | + | ||
| 71 | + | match og::fetch_og_data(&url).await { |
|
| 72 | + | Ok(result) => { |
|
| 73 | + | let og_image = result.og_tags.get("og:image").cloned(); |
|
| 74 | + | ||
| 75 | + | let mut found_tags = Vec::new(); |
|
| 76 | + | let mut missing_tags = Vec::new(); |
|
| 77 | + | ||
| 78 | + | for &tag in COMMON_TAGS { |
|
| 79 | + | if let Some(value) = result.og_tags.get(tag) { |
|
| 80 | + | found_tags.push((tag.to_string(), value.clone())); |
|
| 81 | + | } else { |
|
| 82 | + | missing_tags.push(tag.to_string()); |
|
| 83 | + | } |
|
| 84 | + | } |
|
| 85 | + | ||
| 86 | + | for (key, value) in &result.og_tags { |
|
| 87 | + | if !COMMON_TAGS.contains(&key.as_str()) { |
|
| 88 | + | found_tags.push((key.clone(), value.clone())); |
|
| 89 | + | } |
|
| 90 | + | } |
|
| 91 | + | ||
| 92 | + | let link_tags: Vec<(String, String, String)> = result |
|
| 93 | + | .link_tags |
|
| 94 | + | .into_iter() |
|
| 95 | + | .map(|lt| (lt.rel, lt.href, lt.extra)) |
|
| 96 | + | .collect(); |
|
| 97 | + | ||
| 98 | + | let tmpl = ResultsTemplate { |
|
| 99 | + | url, |
|
| 100 | + | error: None, |
|
| 101 | + | og_image, |
|
| 102 | + | favicon: result.favicon, |
|
| 103 | + | found_tags, |
|
| 104 | + | missing_tags, |
|
| 105 | + | link_tags, |
|
| 106 | + | }; |
|
| 107 | + | Html(tmpl.render().unwrap()).into_response() |
|
| 108 | + | } |
|
| 109 | + | Err(err) => { |
|
| 110 | + | let tmpl = ResultsTemplate { |
|
| 111 | + | url, |
|
| 112 | + | error: Some(err), |
|
| 113 | + | og_image: None, |
|
| 114 | + | favicon: None, |
|
| 115 | + | found_tags: Vec::new(), |
|
| 116 | + | missing_tags: Vec::new(), |
|
| 117 | + | link_tags: Vec::new(), |
|
| 118 | + | }; |
|
| 119 | + | Html(tmpl.render().unwrap()).into_response() |
|
| 120 | + | } |
|
| 121 | + | } |
|
| 122 | + | } |
|
| 123 | + | ||
| 124 | + | async fn static_handler(Path(path): Path<String>) -> Response { |
|
| 125 | + | match StaticAssets::get(&path) { |
|
| 126 | + | Some(file) => { |
|
| 127 | + | let mime = file.metadata.mimetype(); |
|
| 128 | + | ( |
|
| 129 | + | StatusCode::OK, |
|
| 130 | + | [(header::CONTENT_TYPE, mime)], |
|
| 131 | + | file.data.to_vec(), |
|
| 132 | + | ) |
|
| 133 | + | .into_response() |
|
| 134 | + | } |
|
| 135 | + | None => (StatusCode::NOT_FOUND, "Not found").into_response(), |
|
| 136 | + | } |
|
| 137 | + | } |
|
| 138 | + | ||
| 139 | + | pub async fn run() { |
|
| 140 | + | let app = Router::new() |
|
| 141 | + | .route("/", get(get_index)) |
|
| 142 | + | .route("/check", post(post_check)) |
|
| 143 | + | .route("/static/{*path}", get(static_handler)); |
|
| 144 | + | ||
| 145 | + | let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string()); |
|
| 146 | + | let addr = format!("0.0.0.0:{port}"); |
|
| 147 | + | tracing::info!("Listening on http://localhost:{port}"); |
|
| 148 | + | let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); |
|
| 149 | + | axum::serve(listener, app).await.unwrap(); |
|
| 150 | + | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | + | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | + | @font-face { |
|
| 2 | + | font-family: "Commit Mono"; |
|
| 3 | + | src: url("/static/assets/fonts/CommitMono-400-Regular.otf") format("opentype"); |
|
| 4 | + | font-weight: 400; |
|
| 5 | + | font-style: normal; |
|
| 6 | + | } |
|
| 7 | + | ||
| 8 | + | @font-face { |
|
| 9 | + | font-family: "Commit Mono"; |
|
| 10 | + | src: url("/static/assets/fonts/CommitMono-700-Regular.otf") format("opentype"); |
|
| 11 | + | font-weight: 700; |
|
| 12 | + | font-style: normal; |
|
| 13 | + | } |
|
| 14 | + | ||
| 15 | + | * { |
|
| 16 | + | padding: 0; |
|
| 17 | + | margin: 0; |
|
| 18 | + | box-sizing: border-box; |
|
| 19 | + | font-family: "Commit Mono", monospace, sans-serif; |
|
| 20 | + | scrollbar-width: none; |
|
| 21 | + | -ms-overflow-style: none; |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | html { |
|
| 25 | + | background: #121113; |
|
| 26 | + | color: #ffffff; |
|
| 27 | + | font-size: 14px; |
|
| 28 | + | line-height: 1.6; |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | html::-webkit-scrollbar { |
|
| 32 | + | display: none; |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | body { |
|
| 36 | + | display: flex; |
|
| 37 | + | flex-direction: column; |
|
| 38 | + | justify-content: start; |
|
| 39 | + | align-items: start; |
|
| 40 | + | gap: 1.5rem; |
|
| 41 | + | min-height: 100vh; |
|
| 42 | + | max-width: 700px; |
|
| 43 | + | margin: auto; |
|
| 44 | + | padding: 0 1rem 6rem; |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | a { |
|
| 48 | + | color: #ffffff; |
|
| 49 | + | text-decoration: none; |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | a:hover { |
|
| 53 | + | opacity: 0.7; |
|
| 54 | + | } |
|
| 55 | + | ||
| 56 | + | /* === HEADER === */ |
|
| 57 | + | ||
| 58 | + | .header { |
|
| 59 | + | display: flex; |
|
| 60 | + | flex-direction: column; |
|
| 61 | + | gap: 0.5rem; |
|
| 62 | + | width: 100%; |
|
| 63 | + | margin-top: 2rem; |
|
| 64 | + | border-bottom: 1px solid #333; |
|
| 65 | + | padding-bottom: 1rem; |
|
| 66 | + | } |
|
| 67 | + | ||
| 68 | + | .logo { |
|
| 69 | + | font-size: 28px; |
|
| 70 | + | font-weight: 700; |
|
| 71 | + | text-decoration: none; |
|
| 72 | + | text-transform: uppercase; |
|
| 73 | + | } |
|
| 74 | + | ||
| 75 | + | /* === INDEX PAGE === */ |
|
| 76 | + | ||
| 77 | + | .index-container { |
|
| 78 | + | width: 100%; |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | .description { |
|
| 82 | + | opacity: 0.7; |
|
| 83 | + | } |
|
| 84 | + | ||
| 85 | + | .check-form { |
|
| 86 | + | display: flex; |
|
| 87 | + | flex-wrap: nowrap; |
|
| 88 | + | gap: 0.5rem; |
|
| 89 | + | width: 100%; |
|
| 90 | + | } |
|
| 91 | + | ||
| 92 | + | .check-form input { |
|
| 93 | + | flex: 1; |
|
| 94 | + | background: #121113; |
|
| 95 | + | color: #ffffff; |
|
| 96 | + | border: 1px solid white; |
|
| 97 | + | padding: 0.4rem 0.75rem; |
|
| 98 | + | font-size: 14px; |
|
| 99 | + | border-radius: 0; |
|
| 100 | + | outline: none; |
|
| 101 | + | width: 100%; |
|
| 102 | + | } |
|
| 103 | + | ||
| 104 | + | .check-form input::placeholder { |
|
| 105 | + | opacity: 0.3; |
|
| 106 | + | } |
|
| 107 | + | ||
| 108 | + | .check-form button { |
|
| 109 | + | background: #121113; |
|
| 110 | + | color: #ffffff; |
|
| 111 | + | padding: 0.4rem 0.75rem; |
|
| 112 | + | border: 1px solid white; |
|
| 113 | + | cursor: pointer; |
|
| 114 | + | width: fit-content; |
|
| 115 | + | flex-shrink: 0; |
|
| 116 | + | white-space: nowrap; |
|
| 117 | + | font-size: 14px; |
|
| 118 | + | font-weight: 700; |
|
| 119 | + | border-radius: 0; |
|
| 120 | + | } |
|
| 121 | + | ||
| 122 | + | .check-form button:hover { |
|
| 123 | + | opacity: 0.7; |
|
| 124 | + | } |
|
| 125 | + | ||
| 126 | + | /* === RESULTS PAGE === */ |
|
| 127 | + | ||
| 128 | + | .results-container { |
|
| 129 | + | display: flex; |
|
| 130 | + | flex-direction: column; |
|
| 131 | + | gap: 1.5rem; |
|
| 132 | + | width: 100%; |
|
| 133 | + | } |
|
| 134 | + | ||
| 135 | + | .results-header { |
|
| 136 | + | display: flex; |
|
| 137 | + | flex-direction: column; |
|
| 138 | + | gap: 0.75rem; |
|
| 139 | + | padding-bottom: 1rem; |
|
| 140 | + | border-bottom: 1px solid #333; |
|
| 141 | + | } |
|
| 142 | + | ||
| 143 | + | .results-url a { |
|
| 144 | + | word-break: break-all; |
|
| 145 | + | } |
|
| 146 | + | ||
| 147 | + | label, .label { |
|
| 148 | + | display: block; |
|
| 149 | + | font-size: 12px; |
|
| 150 | + | opacity: 0.7; |
|
| 151 | + | text-transform: uppercase; |
|
| 152 | + | margin-bottom: 0.25rem; |
|
| 153 | + | } |
|
| 154 | + | ||
| 155 | + | .results-meta span:last-child { |
|
| 156 | + | opacity: 0.5; |
|
| 157 | + | font-size: 12px; |
|
| 158 | + | } |
|
| 159 | + | ||
| 160 | + | /* === ERROR === */ |
|
| 161 | + | ||
| 162 | + | .error { |
|
| 163 | + | border-left: 2px solid #ffffff; |
|
| 164 | + | padding-left: 0.5rem; |
|
| 165 | + | font-size: 13px; |
|
| 166 | + | opacity: 0.8; |
|
| 167 | + | } |
|
| 168 | + | ||
| 169 | + | .error h2 { |
|
| 170 | + | font-size: 12px; |
|
| 171 | + | text-transform: uppercase; |
|
| 172 | + | margin-bottom: 0.25rem; |
|
| 173 | + | } |
|
| 174 | + | ||
| 175 | + | /* === PREVIEWS === */ |
|
| 176 | + | ||
| 177 | + | .preview-section { |
|
| 178 | + | display: flex; |
|
| 179 | + | flex-direction: column; |
|
| 180 | + | gap: 0.5rem; |
|
| 181 | + | } |
|
| 182 | + | ||
| 183 | + | .preview-section h2 { |
|
| 184 | + | font-size: 12px; |
|
| 185 | + | text-transform: uppercase; |
|
| 186 | + | opacity: 0.5; |
|
| 187 | + | } |
|
| 188 | + | ||
| 189 | + | .image-preview { |
|
| 190 | + | border: 1px solid #333; |
|
| 191 | + | overflow: hidden; |
|
| 192 | + | } |
|
| 193 | + | ||
| 194 | + | .image-preview img { |
|
| 195 | + | display: block; |
|
| 196 | + | width: 100%; |
|
| 197 | + | height: auto; |
|
| 198 | + | } |
|
| 199 | + | ||
| 200 | + | /* === TAGS === */ |
|
| 201 | + | ||
| 202 | + | .tag-section { |
|
| 203 | + | display: flex; |
|
| 204 | + | flex-direction: column; |
|
| 205 | + | width: 100%; |
|
| 206 | + | } |
|
| 207 | + | ||
| 208 | + | .tag-section h2 { |
|
| 209 | + | font-size: 12px; |
|
| 210 | + | text-transform: uppercase; |
|
| 211 | + | opacity: 0.5; |
|
| 212 | + | margin-bottom: 0.5rem; |
|
| 213 | + | } |
|
| 214 | + | ||
| 215 | + | .tag-item { |
|
| 216 | + | display: flex; |
|
| 217 | + | gap: 1rem; |
|
| 218 | + | padding: 0.75rem 0; |
|
| 219 | + | border-bottom: 1px solid #333; |
|
| 220 | + | font-size: 14px; |
|
| 221 | + | } |
|
| 222 | + | ||
| 223 | + | .tag-item.found { |
|
| 224 | + | border-left: 2px solid #ffffff; |
|
| 225 | + | padding-left: 0.75rem; |
|
| 226 | + | } |
|
| 227 | + | ||
| 228 | + | .tag-item.missing { |
|
| 229 | + | border-left: 2px solid #555; |
|
| 230 | + | padding-left: 0.75rem; |
|
| 231 | + | opacity: 0.3; |
|
| 232 | + | } |
|
| 233 | + | ||
| 234 | + | .tag-key { |
|
| 235 | + | font-weight: 700; |
|
| 236 | + | min-width: 140px; |
|
| 237 | + | max-width: 200px; |
|
| 238 | + | opacity: 0.7; |
|
| 239 | + | word-break: break-all; |
|
| 240 | + | } |
|
| 241 | + | ||
| 242 | + | .tag-value { |
|
| 243 | + | word-break: break-all; |
|
| 244 | + | min-width: 0; |
|
| 245 | + | flex: 1; |
|
| 246 | + | } |
|
| 247 | + | ||
| 248 | + | .tag-item.missing .tag-value { |
|
| 249 | + | font-style: italic; |
|
| 250 | + | } |
|
| 251 | + | ||
| 252 | + | .tag-extra { |
|
| 253 | + | display: block; |
|
| 254 | + | font-size: 12px; |
|
| 255 | + | opacity: 0.5; |
|
| 256 | + | margin-top: 0.15rem; |
|
| 257 | + | } |
|
| 258 | + | ||
| 259 | + | .empty-state { |
|
| 260 | + | opacity: 0.5; |
|
| 261 | + | font-size: 14px; |
|
| 262 | + | } |
|
| 263 | + | ||
| 264 | + | /* === BACK LINK === */ |
|
| 265 | + | ||
| 266 | + | .back-link { |
|
| 267 | + | display: inline-block; |
|
| 268 | + | font-size: 12px; |
|
| 269 | + | opacity: 0.5; |
|
| 270 | + | padding-top: 1rem; |
|
| 271 | + | border-top: 1px solid #333; |
|
| 272 | + | width: 100%; |
|
| 273 | + | } |
|
| 274 | + | ||
| 275 | + | .back-link:hover { |
|
| 276 | + | opacity: 0.7; |
|
| 277 | + | } |
|
| 278 | + | ||
| 279 | + | /* === RESPONSIVE === */ |
|
| 280 | + | ||
| 281 | + | @media (max-width: 480px) { |
|
| 282 | + | body { |
|
| 283 | + | padding: 1rem; |
|
| 284 | + | gap: 1rem; |
|
| 285 | + | } |
|
| 286 | + | ||
| 287 | + | .tag-item { |
|
| 288 | + | flex-direction: column; |
|
| 289 | + | gap: 0.25rem; |
|
| 290 | + | } |
|
| 291 | + | ||
| 292 | + | .tag-key { |
|
| 293 | + | min-width: unset; |
|
| 294 | + | } |
|
| 295 | + | } |
| 1 | + | <!DOCTYPE html> |
|
| 2 | + | <html lang="en"> |
|
| 3 | + | <head> |
|
| 4 | + | <meta charset="UTF-8"> |
|
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
| 6 | + | <meta name="theme-color" content="#121113"> |
|
| 7 | + | <title>{% block title %}OG{% endblock %}</title> |
|
| 8 | + | <meta name="description" content="Check and preview OpenGraph tags for any URL"> |
|
| 9 | + | <meta property="og:title" content="OG Preview"> |
|
| 10 | + | <meta property="og:description" content="Check and preview OpenGraph tags for any URL"> |
|
| 11 | + | <meta property="og:image" content="/static/og.png"> |
|
| 12 | + | <meta property="og:type" content="website"> |
|
| 13 | + | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 14 | + | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 15 | + | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 16 | + | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 17 | + | <link rel="stylesheet" href="/static/styles.css"> |
|
| 18 | + | </head> |
|
| 19 | + | <body> |
|
| 20 | + | <header class="header"> |
|
| 21 | + | <a href="/" class="logo">OG</a> |
|
| 22 | + | </header> |
|
| 23 | + | {% block content %}{% endblock %} |
|
| 24 | + | </body> |
|
| 25 | + | </html> |
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | ||
| 3 | + | {% block title %}OG{% endblock %} |
|
| 4 | + | ||
| 5 | + | {% block content %} |
|
| 6 | + | <div class="index-container"> |
|
| 7 | + | <form action="/check" method="POST" class="check-form"> |
|
| 8 | + | <input |
|
| 9 | + | type="text" |
|
| 10 | + | name="url" |
|
| 11 | + | placeholder="example.com" |
|
| 12 | + | required |
|
| 13 | + | autocomplete="url" |
|
| 14 | + | autofocus |
|
| 15 | + | > |
|
| 16 | + | <button type="submit">Check</button> |
|
| 17 | + | </form> |
|
| 18 | + | </div> |
|
| 19 | + | {% endblock %} |
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | ||
| 3 | + | {% block title %}Results — {{ url }}{% endblock %} |
|
| 4 | + | ||
| 5 | + | {% block content %} |
|
| 6 | + | <div class="results-container"> |
|
| 7 | + | <div class="results-header"> |
|
| 8 | + | <div class="results-url"> |
|
| 9 | + | <span class="label">URL</span> |
|
| 10 | + | <a href="{{ url }}" target="_blank" rel="noopener">{{ url }}</a> |
|
| 11 | + | </div> |
|
| 12 | + | </div> |
|
| 13 | + | ||
| 14 | + | {% if let Some(err) = error %} |
|
| 15 | + | <div class="error"> |
|
| 16 | + | <h2>Error</h2> |
|
| 17 | + | <p>{{ err }}</p> |
|
| 18 | + | </div> |
|
| 19 | + | {% else %} |
|
| 20 | + | ||
| 21 | + | {% if let Some(image) = og_image %} |
|
| 22 | + | <div class="preview-section"> |
|
| 23 | + | <h2>Image Preview</h2> |
|
| 24 | + | <div class="image-preview"> |
|
| 25 | + | <img src="{{ image }}" alt="OG Image preview" loading="lazy"> |
|
| 26 | + | </div> |
|
| 27 | + | </div> |
|
| 28 | + | {% endif %} |
|
| 29 | + | ||
| 30 | + | {% if let Some(fav) = favicon %} |
|
| 31 | + | <div class="preview-section"> |
|
| 32 | + | <h2>Favicon</h2> |
|
| 33 | + | <img src="{{ fav }}" alt="Favicon" width="32" height="32"> |
|
| 34 | + | </div> |
|
| 35 | + | {% endif %} |
|
| 36 | + | ||
| 37 | + | <div class="tag-section"> |
|
| 38 | + | <h2>Found Tags</h2> |
|
| 39 | + | {% if found_tags.is_empty() %} |
|
| 40 | + | <p class="empty-state">No OpenGraph tags found.</p> |
|
| 41 | + | {% else %} |
|
| 42 | + | {% for (key, value) in &found_tags %} |
|
| 43 | + | <div class="tag-item found"> |
|
| 44 | + | <span class="tag-key">{{ key }}</span> |
|
| 45 | + | <span class="tag-value">{{ value }}</span> |
|
| 46 | + | </div> |
|
| 47 | + | {% endfor %} |
|
| 48 | + | {% endif %} |
|
| 49 | + | </div> |
|
| 50 | + | ||
| 51 | + | <div class="tag-section"> |
|
| 52 | + | <h2>Missing Tags</h2> |
|
| 53 | + | {% if missing_tags.is_empty() %} |
|
| 54 | + | <p class="empty-state">All common tags present.</p> |
|
| 55 | + | {% else %} |
|
| 56 | + | {% for tag in &missing_tags %} |
|
| 57 | + | <div class="tag-item missing"> |
|
| 58 | + | <span class="tag-key">{{ tag }}</span> |
|
| 59 | + | <span class="tag-value">not found</span> |
|
| 60 | + | </div> |
|
| 61 | + | {% endfor %} |
|
| 62 | + | {% endif %} |
|
| 63 | + | </div> |
|
| 64 | + | ||
| 65 | + | {% if !link_tags.is_empty() %} |
|
| 66 | + | <div class="tag-section"> |
|
| 67 | + | <h2>Link Tags</h2> |
|
| 68 | + | {% for (rel, href, extra) in &link_tags %} |
|
| 69 | + | <div class="tag-item found"> |
|
| 70 | + | <span class="tag-key">{% if rel.is_empty() %}link{% else %}{{ rel }}{% endif %}</span> |
|
| 71 | + | <span class="tag-value"> |
|
| 72 | + | {% if !href.is_empty() %} |
|
| 73 | + | <a href="{{ href }}" target="_blank" rel="noopener">{{ href }}</a> |
|
| 74 | + | {% else %} |
|
| 75 | + | <span class="tag-extra">no href</span> |
|
| 76 | + | {% endif %} |
|
| 77 | + | {% if !extra.is_empty() %} |
|
| 78 | + | <span class="tag-extra">{{ extra }}</span> |
|
| 79 | + | {% endif %} |
|
| 80 | + | </span> |
|
| 81 | + | </div> |
|
| 82 | + | {% endfor %} |
|
| 83 | + | </div> |
|
| 84 | + | {% endif %} |
|
| 85 | + | ||
| 86 | + | {% endif %} |
|
| 87 | + | </div> |
|
| 88 | + | {% endblock %} |
| 1 | + | APP_PASSWORD=changeme |
|
| 2 | + | DATABASE_URL=sqlite://parcels.db |
|
| 3 | + | USPS_CLIENT_ID=your_client_id_here |
|
| 4 | + | USPS_CLIENT_SECRET=your_client_secret_here |
|
| 5 | + | BIND_ADDR=0.0.0.0:3000 |
|
| 6 | + | COOKIE_SECURE=false |
| 1 | + | [package] |
|
| 2 | + | name = "parcels" |
|
| 3 | + | version = "0.1.0" |
|
| 4 | + | edition = "2024" |
|
| 5 | + | ||
| 6 | + | [[bin]] |
|
| 7 | + | name = "parcels" |
|
| 8 | + | path = "src/main.rs" |
|
| 9 | + | ||
| 10 | + | [dependencies] |
|
| 11 | + | axum = { workspace = true } |
|
| 12 | + | tokio = { workspace = true } |
|
| 13 | + | serde = { workspace = true } |
|
| 14 | + | serde_json = { workspace = true } |
|
| 15 | + | tower-http = { workspace = true, features = ["fs"] } |
|
| 16 | + | rand = { workspace = true } |
|
| 17 | + | subtle = { workspace = true } |
|
| 18 | + | tracing = { workspace = true } |
|
| 19 | + | tracing-subscriber = { workspace = true, features = ["env-filter"] } |
|
| 20 | + | andromeda-auth = { workspace = true } |
|
| 21 | + | rusqlite = { workspace = true } |
|
| 22 | + | reqwest = { version = "0.12", features = ["json"] } |
|
| 23 | + | askama = { version = "0.12", features = ["with-axum"] } |
|
| 24 | + | askama_axum = "0.4" |
|
| 25 | + | anyhow = "1" |
| 1 | + | # Build from repo root: docker build -t parcels -f apps/parcels/Dockerfile . |
|
| 2 | + | FROM rust:1-slim-bookworm AS builder |
|
| 3 | + | WORKDIR /app |
|
| 4 | + | ||
| 5 | + | # Copy workspace manifests |
|
| 6 | + | COPY Cargo.toml Cargo.lock ./ |
|
| 7 | + | COPY crates/auth/Cargo.toml crates/auth/ |
|
| 8 | + | COPY apps/sipp/Cargo.toml apps/sipp/ |
|
| 9 | + | COPY apps/feeds/Cargo.toml apps/feeds/ |
|
| 10 | + | COPY apps/parcels/Cargo.toml apps/parcels/ |
|
| 11 | + | COPY apps/jotts/Cargo.toml apps/jotts/ |
|
| 12 | + | COPY apps/og/Cargo.toml apps/og/ |
|
| 13 | + | COPY apps/shrink/Cargo.toml apps/shrink/ |
|
| 14 | + | ||
| 15 | + | # Create stubs for dependency caching |
|
| 16 | + | RUN mkdir -p crates/auth/src && echo '' > crates/auth/src/lib.rs \ |
|
| 17 | + | && for app in sipp feeds parcels jotts og shrink; do \ |
|
| 18 | + | mkdir -p apps/$app/src && echo 'fn main() {}' > apps/$app/src/main.rs; \ |
|
| 19 | + | done |
|
| 20 | + | ||
| 21 | + | RUN cargo build --release -p parcels |
|
| 22 | + | ||
| 23 | + | # Copy real source |
|
| 24 | + | COPY crates/auth/src crates/auth/src |
|
| 25 | + | COPY apps/parcels/src apps/parcels/src |
|
| 26 | + | COPY apps/parcels/templates apps/parcels/templates |
|
| 27 | + | COPY apps/parcels/static apps/parcels/static |
|
| 28 | + | ||
| 29 | + | RUN touch apps/parcels/src/*.rs crates/auth/src/*.rs && cargo build --release -p parcels |
|
| 30 | + | ||
| 31 | + | FROM debian:bookworm-slim |
|
| 32 | + | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* |
|
| 33 | + | WORKDIR /app |
|
| 34 | + | COPY --from=builder /app/target/release/parcels ./parcels |
|
| 35 | + | COPY --from=builder /app/apps/parcels/static ./static |
|
| 36 | + | COPY --from=builder /app/apps/parcels/templates ./templates |
|
| 37 | + | EXPOSE 3012 |
|
| 38 | + | CMD ["./parcels"] |
| 1 | + | MIT License |
|
| 2 | + | ||
| 3 | + | Copyright (c) 2026 Steve Simkins |
|
| 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 | + |
| 1 | + | # Parcels |
|
| 2 | + | ||
| 3 | + |  |
|
| 4 | + | ||
| 5 | + | A minimal package tracking app |
|
| 6 | + | ||
| 7 | + | >[!NOTE] |
|
| 8 | + | >Parcels currently only supports USPS tracking. If you're interested in adding other providers please feel free to open an issue |
|
| 9 | + | ||
| 10 | + | ## Quickstart |
|
| 11 | + | ||
| 12 | + | ```bash |
|
| 13 | + | git clone https://github.com/stevedylandev/parcels.git |
|
| 14 | + | cd parcels |
|
| 15 | + | cp .env.example .env |
|
| 16 | + | # Edit .env with your USPS API credentials and app password |
|
| 17 | + | cargo build --release |
|
| 18 | + | ./target/release/parcels |
|
| 19 | + | ``` |
|
| 20 | + | ||
| 21 | + | You'll need a [USPS Web Tools API](https://developer.usps.com) account to get your `USPS_CLIENT_ID` and `USPS_CLIENT_SECRET`. |
|
| 22 | + | ||
| 23 | + | ### Environment Variables |
|
| 24 | + | ||
| 25 | + | | Variable | Description | Default | |
|
| 26 | + | |---|---|---| |
|
| 27 | + | | `APP_PASSWORD` | Password for login authentication | *required* | |
|
| 28 | + | | `DATABASE_URL` | SQLite database path (e.g. `sqlite:///app/data/parcels.db`) | |
|
| 29 | + | | `USPS_CLIENT_ID` | USPS OAuth2 client ID | *required* | |
|
| 30 | + | | `USPS_CLIENT_SECRET` | USPS OAuth2 client secret | *required* | |
|
| 31 | + | | `BIND_ADDR` | Server bind address | `0.0.0.0:3012` | |
|
| 32 | + | | `COOKIE_SECURE` | Enable HTTPS-only cookies | `false` | |
|
| 33 | + | ||
| 34 | + | ## Overview |
|
| 35 | + | ||
| 36 | + | I got tired of logging into USPS, so I built this to track my own personal packages. Over time I might add more providers, but it currently gets the job done. Here's a few highlights: |
|
| 37 | + | - Single ~7MB Rust binary |
|
| 38 | + | - Averages around ~10MB of Ram usage |
|
| 39 | + | - Password authentication |
|
| 40 | + | - Track USPS packages with custom labels |
|
| 41 | + | - Delete packages you no longer want to track |
|
| 42 | + | ||
| 43 | + | ## Structure |
|
| 44 | + | ||
| 45 | + | ``` |
|
| 46 | + | parcels/ |
|
| 47 | + | ├── src/ |
|
| 48 | + | │ ├── main.rs # Axum web server, routes, and app state |
|
| 49 | + | │ ├── auth.rs # Password verification and session management |
|
| 50 | + | │ ├── db.rs # SQLite database layer (packages, events, sessions) |
|
| 51 | + | │ └── usps.rs # USPS API integration with OAuth2 token caching |
|
| 52 | + | ├── templates/ # Askama HTML templates |
|
| 53 | + | │ ├── base.html # Base layout |
|
| 54 | + | │ ├── index.html # Package list |
|
| 55 | + | │ ├── detail.html # Package detail with tracking events |
|
| 56 | + | │ ├── add.html # Add package form |
|
| 57 | + | │ └── login.html # Login page |
|
| 58 | + | ├── static/ # Fonts, favicons, and images |
|
| 59 | + | ├── Dockerfile # Multi-stage build (Rust 1.87 + Debian slim) |
|
| 60 | + | └── docker-compose.yml |
|
| 61 | + | ``` |
|
| 62 | + | ||
| 63 | + | ## Deployment |
|
| 64 | + | ||
| 65 | + | ### Docker (recommended) |
|
| 66 | + | ||
| 67 | + | ```bash |
|
| 68 | + | git clone https://github.com/stevedylandev/parcels.git |
|
| 69 | + | cd parcels |
|
| 70 | + | cp .env.example .env |
|
| 71 | + | # Edit .env with your credentials |
|
| 72 | + | docker compose up -d |
|
| 73 | + | ``` |
|
| 74 | + | ||
| 75 | + | This will start Parcels on port `3012` with a persistent volume for the SQLite database. |
|
| 76 | + | ||
| 77 | + | ### Binary |
|
| 78 | + | ||
| 79 | + | ```bash |
|
| 80 | + | cargo build --release |
|
| 81 | + | ``` |
|
| 82 | + | ||
| 83 | + | The resulting binary at `./target/release/parcels` is self-contained (~7MB). Copy it to your server along with the `static/` directory and a configured `.env` file, then run it directly. |
|
| 84 | + | ||
| 85 | + | ## License |
|
| 86 | + | ||
| 87 | + | [MIT](LICENSE) |
| 1 | + | services: |
|
| 2 | + | parcels: |
|
| 3 | + | build: |
|
| 4 | + | context: ../.. |
|
| 5 | + | dockerfile: apps/parcels/Dockerfile |
|
| 6 | + | ports: |
|
| 7 | + | - "3012:3012" |
|
| 8 | + | volumes: |
|
| 9 | + | - parcels_data:/app/data |
|
| 10 | + | env_file: |
|
| 11 | + | - .env |
|
| 12 | + | restart: unless-stopped |
|
| 13 | + | ||
| 14 | + | volumes: |
|
| 15 | + | parcels_data: |
| 1 | + | use axum::{ |
|
| 2 | + | extract::{FromRef, FromRequestParts}, |
|
| 3 | + | http::request::Parts, |
|
| 4 | + | response::{IntoResponse, Redirect, Response}, |
|
| 5 | + | }; |
|
| 6 | + | use std::sync::Arc; |
|
| 7 | + | ||
| 8 | + | use crate::AppState; |
|
| 9 | + | ||
| 10 | + | pub use andromeda_auth::{ |
|
| 11 | + | build_session_cookie, clear_session_cookie, extract_session_cookie, generate_session_token, |
|
| 12 | + | verify_password, |
|
| 13 | + | }; |
|
| 14 | + | ||
| 15 | + | // ── Session Token ────────────────────────────────────────────────────────── |
|
| 16 | + | ||
| 17 | + | /// Return an ISO datetime string 7 days from now. |
|
| 18 | + | pub fn session_expiry_at() -> String { |
|
| 19 | + | use std::time::{SystemTime, UNIX_EPOCH}; |
|
| 20 | + | let secs = SystemTime::now() |
|
| 21 | + | .duration_since(UNIX_EPOCH) |
|
| 22 | + | .unwrap() |
|
| 23 | + | .as_secs() |
|
| 24 | + | + 7 * 24 * 3600; |
|
| 25 | + | let dt = secs; |
|
| 26 | + | let s = dt % 60; |
|
| 27 | + | let m = (dt / 60) % 60; |
|
| 28 | + | let h = (dt / 3600) % 24; |
|
| 29 | + | let days_since_epoch = dt / 86400; |
|
| 30 | + | format_unix_to_datetime(days_since_epoch, h, m, s) |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | fn format_unix_to_datetime(days: u64, h: u64, m: u64, s: u64) -> String { |
|
| 34 | + | // https://howardhinnant.github.io/date_algorithms.html |
|
| 35 | + | let z = days as i64 + 719468; |
|
| 36 | + | let era = if z >= 0 { z } else { z - 146096 } / 146097; |
|
| 37 | + | let doe = (z - era * 146097) as u64; |
|
| 38 | + | let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; |
|
| 39 | + | let y = yoe as i64 + era * 400; |
|
| 40 | + | let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); |
|
| 41 | + | let mp = (5 * doy + 2) / 153; |
|
| 42 | + | let d = doy - (153 * mp + 2) / 5 + 1; |
|
| 43 | + | let mo = if mp < 10 { mp + 3 } else { mp - 9 }; |
|
| 44 | + | let y = if mo <= 2 { y + 1 } else { y }; |
|
| 45 | + | format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", y, mo, d, h, m, s) |
|
| 46 | + | } |
|
| 47 | + | ||
| 48 | + | pub fn format_unix_to_datetime_pub(days: u64, h: u64, m: u64, s: u64) -> String { |
|
| 49 | + | format_unix_to_datetime(days, h, m, s) |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | pub fn extract_session_token(headers: &axum::http::HeaderMap) -> Option<String> { |
|
| 53 | + | extract_session_cookie(headers) |
|
| 54 | + | } |
|
| 55 | + | ||
| 56 | + | // ── Axum Extractor ───────────────────────────────────────────────────────── |
|
| 57 | + | ||
| 58 | + | /// Authenticated session guard. Extract from request; redirects to /login if not valid. |
|
| 59 | + | pub struct AuthSession; |
|
| 60 | + | ||
| 61 | + | impl<S> FromRequestParts<S> for AuthSession |
|
| 62 | + | where |
|
| 63 | + | S: Send + Sync, |
|
| 64 | + | Arc<AppState>: FromRef<S>, |
|
| 65 | + | { |
|
| 66 | + | type Rejection = Response; |
|
| 67 | + | ||
| 68 | + | async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { |
|
| 69 | + | let state = Arc::<AppState>::from_ref(state); |
|
| 70 | + | let token = extract_session_cookie(&parts.headers); |
|
| 71 | + | ||
| 72 | + | if let Some(token) = token { |
|
| 73 | + | if is_valid_session(&state, &token).await { |
|
| 74 | + | return Ok(AuthSession); |
|
| 75 | + | } |
|
| 76 | + | } |
|
| 77 | + | ||
| 78 | + | Err(Redirect::to("/login").into_response()) |
|
| 79 | + | } |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | async fn is_valid_session(state: &AppState, token: &str) -> bool { |
|
| 83 | + | match crate::db::get_session_expiry(&state.db, token) { |
|
| 84 | + | Ok(Some(expires_at)) => { |
|
| 85 | + | use std::time::{SystemTime, UNIX_EPOCH}; |
|
| 86 | + | let now_secs = SystemTime::now() |
|
| 87 | + | .duration_since(UNIX_EPOCH) |
|
| 88 | + | .unwrap() |
|
| 89 | + | .as_secs(); |
|
| 90 | + | let now_str = { |
|
| 91 | + | let days = now_secs / 86400; |
|
| 92 | + | let h = (now_secs / 3600) % 24; |
|
| 93 | + | let m = (now_secs / 60) % 60; |
|
| 94 | + | let s = now_secs % 60; |
|
| 95 | + | format_unix_to_datetime(days, h, m, s) |
|
| 96 | + | }; |
|
| 97 | + | expires_at > now_str |
|
| 98 | + | } |
|
| 99 | + | _ => false, |
|
| 100 | + | } |
|
| 101 | + | } |
| 1 | + | use rusqlite::{Connection, params}; |
|
| 2 | + | use std::fmt; |
|
| 3 | + | use std::sync::{Arc, Mutex}; |
|
| 4 | + | ||
| 5 | + | pub type Db = Arc<Mutex<Connection>>; |
|
| 6 | + | ||
| 7 | + | // ── Error ─────────────────────────────────────────────────────────────────── |
|
| 8 | + | ||
| 9 | + | #[derive(Debug)] |
|
| 10 | + | pub enum DbError { |
|
| 11 | + | Sqlite(rusqlite::Error), |
|
| 12 | + | LockPoisoned, |
|
| 13 | + | } |
|
| 14 | + | ||
| 15 | + | impl fmt::Display for DbError { |
|
| 16 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
|
| 17 | + | match self { |
|
| 18 | + | DbError::Sqlite(e) => write!(f, "Database error: {}", e), |
|
| 19 | + | DbError::LockPoisoned => write!(f, "Database lock poisoned"), |
|
| 20 | + | } |
|
| 21 | + | } |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | impl std::error::Error for DbError {} |
|
| 25 | + | ||
| 26 | + | impl From<rusqlite::Error> for DbError { |
|
| 27 | + | fn from(e: rusqlite::Error) -> Self { |
|
| 28 | + | DbError::Sqlite(e) |
|
| 29 | + | } |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | // ── Types ─────────────────────────────────────────────────────────────────── |
|
| 33 | + | ||
| 34 | + | #[derive(Debug, Clone)] |
|
| 35 | + | pub struct Package { |
|
| 36 | + | pub id: i64, |
|
| 37 | + | pub tracking_number: String, |
|
| 38 | + | pub label: Option<String>, |
|
| 39 | + | pub status: Option<String>, |
|
| 40 | + | pub status_category: Option<String>, |
|
| 41 | + | pub status_summary: Option<String>, |
|
| 42 | + | pub mail_class: Option<String>, |
|
| 43 | + | pub expected_delivery: Option<String>, |
|
| 44 | + | pub last_refreshed_at: Option<String>, |
|
| 45 | + | pub created_at: String, |
|
| 46 | + | } |
|
| 47 | + | ||
| 48 | + | #[derive(Debug, Clone)] |
|
| 49 | + | pub struct TrackingEvent { |
|
| 50 | + | pub id: i64, |
|
| 51 | + | pub package_id: i64, |
|
| 52 | + | pub event_timestamp: Option<String>, |
|
| 53 | + | pub event_type: Option<String>, |
|
| 54 | + | pub event_city: Option<String>, |
|
| 55 | + | pub event_state: Option<String>, |
|
| 56 | + | pub event_zip: Option<String>, |
|
| 57 | + | pub event_code: Option<String>, |
|
| 58 | + | } |
|
| 59 | + | ||
| 60 | + | // ── Pool Setup ────────────────────────────────────────────────────────────── |
|
| 61 | + | ||
| 62 | + | pub fn database_path() -> String { |
|
| 63 | + | let raw = std::env::var("DATABASE_URL").unwrap_or_else(|_| "parcels.db".to_string()); |
|
| 64 | + | // Strip sqlite:// or sqlite:/// prefix if present |
|
| 65 | + | raw.strip_prefix("sqlite:///") |
|
| 66 | + | .or_else(|| raw.strip_prefix("sqlite://")) |
|
| 67 | + | .unwrap_or(&raw) |
|
| 68 | + | .to_string() |
|
| 69 | + | } |
|
| 70 | + | ||
| 71 | + | pub fn init_db(path: &str) -> Result<Db, DbError> { |
|
| 72 | + | if let Some(parent) = std::path::Path::new(path).parent() { |
|
| 73 | + | if parent != std::path::Path::new("") { |
|
| 74 | + | std::fs::create_dir_all(parent).ok(); |
|
| 75 | + | } |
|
| 76 | + | } |
|
| 77 | + | let conn = Connection::open(path)?; |
|
| 78 | + | conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?; |
|
| 79 | + | conn.execute_batch(" |
|
| 80 | + | CREATE TABLE IF NOT EXISTS packages ( |
|
| 81 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 82 | + | tracking_number TEXT NOT NULL UNIQUE, |
|
| 83 | + | label TEXT, |
|
| 84 | + | status TEXT, |
|
| 85 | + | status_category TEXT, |
|
| 86 | + | status_summary TEXT, |
|
| 87 | + | mail_class TEXT, |
|
| 88 | + | expected_delivery TEXT, |
|
| 89 | + | last_refreshed_at TEXT, |
|
| 90 | + | created_at TEXT NOT NULL DEFAULT (datetime('now')) |
|
| 91 | + | ); |
|
| 92 | + | CREATE TABLE IF NOT EXISTS tracking_events ( |
|
| 93 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 94 | + | package_id INTEGER NOT NULL REFERENCES packages(id) ON DELETE CASCADE, |
|
| 95 | + | event_timestamp TEXT, |
|
| 96 | + | event_type TEXT, |
|
| 97 | + | event_city TEXT, |
|
| 98 | + | event_state TEXT, |
|
| 99 | + | event_zip TEXT, |
|
| 100 | + | event_code TEXT |
|
| 101 | + | ); |
|
| 102 | + | CREATE TABLE IF NOT EXISTS sessions ( |
|
| 103 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 104 | + | token TEXT NOT NULL UNIQUE, |
|
| 105 | + | expires_at TEXT NOT NULL |
|
| 106 | + | ); |
|
| 107 | + | ")?; |
|
| 108 | + | Ok(Arc::new(Mutex::new(conn))) |
|
| 109 | + | } |
|
| 110 | + | ||
| 111 | + | // ── Package Queries ───────────────────────────────────────────────────────── |
|
| 112 | + | ||
| 113 | + | pub fn list_packages(db: &Db) -> Result<Vec<Package>, DbError> { |
|
| 114 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 115 | + | let mut stmt = conn.prepare( |
|
| 116 | + | "SELECT id, tracking_number, label, status, status_category, status_summary, |
|
| 117 | + | mail_class, expected_delivery, last_refreshed_at, created_at |
|
| 118 | + | FROM packages ORDER BY created_at DESC", |
|
| 119 | + | )?; |
|
| 120 | + | let packages = stmt |
|
| 121 | + | .query_map([], |row| { |
|
| 122 | + | Ok(Package { |
|
| 123 | + | id: row.get(0)?, |
|
| 124 | + | tracking_number: row.get(1)?, |
|
| 125 | + | label: row.get(2)?, |
|
| 126 | + | status: row.get(3)?, |
|
| 127 | + | status_category: row.get(4)?, |
|
| 128 | + | status_summary: row.get(5)?, |
|
| 129 | + | mail_class: row.get(6)?, |
|
| 130 | + | expected_delivery: row.get(7)?, |
|
| 131 | + | last_refreshed_at: row.get(8)?, |
|
| 132 | + | created_at: row.get(9)?, |
|
| 133 | + | }) |
|
| 134 | + | })? |
|
| 135 | + | .filter_map(|r| r.ok()) |
|
| 136 | + | .collect(); |
|
| 137 | + | Ok(packages) |
|
| 138 | + | } |
|
| 139 | + | ||
| 140 | + | pub fn get_package(db: &Db, id: i64) -> Result<Option<Package>, DbError> { |
|
| 141 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 142 | + | match conn.query_row( |
|
| 143 | + | "SELECT id, tracking_number, label, status, status_category, status_summary, |
|
| 144 | + | mail_class, expected_delivery, last_refreshed_at, created_at |
|
| 145 | + | FROM packages WHERE id = ?1", |
|
| 146 | + | params![id], |
|
| 147 | + | |row| { |
|
| 148 | + | Ok(Package { |
|
| 149 | + | id: row.get(0)?, |
|
| 150 | + | tracking_number: row.get(1)?, |
|
| 151 | + | label: row.get(2)?, |
|
| 152 | + | status: row.get(3)?, |
|
| 153 | + | status_category: row.get(4)?, |
|
| 154 | + | status_summary: row.get(5)?, |
|
| 155 | + | mail_class: row.get(6)?, |
|
| 156 | + | expected_delivery: row.get(7)?, |
|
| 157 | + | last_refreshed_at: row.get(8)?, |
|
| 158 | + | created_at: row.get(9)?, |
|
| 159 | + | }) |
|
| 160 | + | }, |
|
| 161 | + | ) { |
|
| 162 | + | Ok(pkg) => Ok(Some(pkg)), |
|
| 163 | + | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
|
| 164 | + | Err(e) => Err(DbError::Sqlite(e)), |
|
| 165 | + | } |
|
| 166 | + | } |
|
| 167 | + | ||
| 168 | + | pub fn insert_package(db: &Db, tracking_number: &str, label: Option<&str>) -> Result<i64, DbError> { |
|
| 169 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 170 | + | conn.execute( |
|
| 171 | + | "INSERT INTO packages (tracking_number, label) VALUES (?1, ?2)", |
|
| 172 | + | params![tracking_number, label], |
|
| 173 | + | )?; |
|
| 174 | + | Ok(conn.last_insert_rowid()) |
|
| 175 | + | } |
|
| 176 | + | ||
| 177 | + | pub fn update_package_status( |
|
| 178 | + | db: &Db, |
|
| 179 | + | id: i64, |
|
| 180 | + | status: &str, |
|
| 181 | + | status_category: Option<&str>, |
|
| 182 | + | status_summary: Option<&str>, |
|
| 183 | + | mail_class: Option<&str>, |
|
| 184 | + | expected_delivery: Option<&str>, |
|
| 185 | + | last_refreshed_at: &str, |
|
| 186 | + | ) -> Result<(), DbError> { |
|
| 187 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 188 | + | conn.execute( |
|
| 189 | + | "UPDATE packages SET status = ?1, status_category = ?2, status_summary = ?3, |
|
| 190 | + | mail_class = ?4, expected_delivery = ?5, last_refreshed_at = ?6 |
|
| 191 | + | WHERE id = ?7", |
|
| 192 | + | params![status, status_category, status_summary, mail_class, expected_delivery, last_refreshed_at, id], |
|
| 193 | + | )?; |
|
| 194 | + | Ok(()) |
|
| 195 | + | } |
|
| 196 | + | ||
| 197 | + | pub fn delete_package(db: &Db, id: i64) -> Result<(), DbError> { |
|
| 198 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 199 | + | conn.execute("DELETE FROM packages WHERE id = ?1", params![id])?; |
|
| 200 | + | Ok(()) |
|
| 201 | + | } |
|
| 202 | + | ||
| 203 | + | // ── Tracking Event Queries ─────────────────────────────────────────────────── |
|
| 204 | + | ||
| 205 | + | pub fn delete_events_for_package(db: &Db, package_id: i64) -> Result<(), DbError> { |
|
| 206 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 207 | + | conn.execute("DELETE FROM tracking_events WHERE package_id = ?1", params![package_id])?; |
|
| 208 | + | Ok(()) |
|
| 209 | + | } |
|
| 210 | + | ||
| 211 | + | pub fn insert_event( |
|
| 212 | + | db: &Db, |
|
| 213 | + | package_id: i64, |
|
| 214 | + | event_timestamp: Option<&str>, |
|
| 215 | + | event_type: Option<&str>, |
|
| 216 | + | event_city: Option<&str>, |
|
| 217 | + | event_state: Option<&str>, |
|
| 218 | + | event_zip: Option<&str>, |
|
| 219 | + | event_code: Option<&str>, |
|
| 220 | + | ) -> Result<(), DbError> { |
|
| 221 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 222 | + | conn.execute( |
|
| 223 | + | "INSERT INTO tracking_events |
|
| 224 | + | (package_id, event_timestamp, event_type, event_city, event_state, event_zip, event_code) |
|
| 225 | + | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", |
|
| 226 | + | params![package_id, event_timestamp, event_type, event_city, event_state, event_zip, event_code], |
|
| 227 | + | )?; |
|
| 228 | + | Ok(()) |
|
| 229 | + | } |
|
| 230 | + | ||
| 231 | + | pub fn get_events_for_package(db: &Db, package_id: i64) -> Result<Vec<TrackingEvent>, DbError> { |
|
| 232 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 233 | + | let mut stmt = conn.prepare( |
|
| 234 | + | "SELECT id, package_id, event_timestamp, event_type, event_city, event_state, |
|
| 235 | + | event_zip, event_code |
|
| 236 | + | FROM tracking_events WHERE package_id = ?1 |
|
| 237 | + | ORDER BY event_timestamp DESC", |
|
| 238 | + | )?; |
|
| 239 | + | let events = stmt |
|
| 240 | + | .query_map(params![package_id], |row| { |
|
| 241 | + | Ok(TrackingEvent { |
|
| 242 | + | id: row.get(0)?, |
|
| 243 | + | package_id: row.get(1)?, |
|
| 244 | + | event_timestamp: row.get(2)?, |
|
| 245 | + | event_type: row.get(3)?, |
|
| 246 | + | event_city: row.get(4)?, |
|
| 247 | + | event_state: row.get(5)?, |
|
| 248 | + | event_zip: row.get(6)?, |
|
| 249 | + | event_code: row.get(7)?, |
|
| 250 | + | }) |
|
| 251 | + | })? |
|
| 252 | + | .filter_map(|r| r.ok()) |
|
| 253 | + | .collect(); |
|
| 254 | + | Ok(events) |
|
| 255 | + | } |
|
| 256 | + | ||
| 257 | + | // ── Session Queries ────────────────────────────────────────────────────────── |
|
| 258 | + | ||
| 259 | + | pub fn insert_session(db: &Db, token: &str, expires_at: &str) -> Result<(), DbError> { |
|
| 260 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 261 | + | conn.execute( |
|
| 262 | + | "INSERT INTO sessions (token, expires_at) VALUES (?1, ?2)", |
|
| 263 | + | params![token, expires_at], |
|
| 264 | + | )?; |
|
| 265 | + | Ok(()) |
|
| 266 | + | } |
|
| 267 | + | ||
| 268 | + | pub fn get_session_expiry(db: &Db, token: &str) -> Result<Option<String>, DbError> { |
|
| 269 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 270 | + | match conn.query_row( |
|
| 271 | + | "SELECT expires_at FROM sessions WHERE token = ?1", |
|
| 272 | + | params![token], |
|
| 273 | + | |row| row.get(0), |
|
| 274 | + | ) { |
|
| 275 | + | Ok(expires_at) => Ok(Some(expires_at)), |
|
| 276 | + | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
|
| 277 | + | Err(e) => Err(DbError::Sqlite(e)), |
|
| 278 | + | } |
|
| 279 | + | } |
|
| 280 | + | ||
| 281 | + | pub fn delete_session(db: &Db, token: &str) -> Result<(), DbError> { |
|
| 282 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 283 | + | conn.execute("DELETE FROM sessions WHERE token = ?1", params![token])?; |
|
| 284 | + | Ok(()) |
|
| 285 | + | } |
|
| 286 | + | ||
| 287 | + | pub fn prune_expired_sessions(db: &Db) -> Result<(), DbError> { |
|
| 288 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 289 | + | conn.execute("DELETE FROM sessions WHERE expires_at < datetime('now')", [])?; |
|
| 290 | + | Ok(()) |
|
| 291 | + | } |
| 1 | + | mod db; |
|
| 2 | + | mod auth; |
|
| 3 | + | mod usps; |
|
| 4 | + | ||
| 5 | + | use askama::Template; |
|
| 6 | + | use axum::{ |
|
| 7 | + | Form, |
|
| 8 | + | Router, |
|
| 9 | + | extract::{Path, Query, State}, |
|
| 10 | + | http::{HeaderMap, StatusCode}, |
|
| 11 | + | response::{Html, IntoResponse, Redirect, Response}, |
|
| 12 | + | routing::{get, post}, |
|
| 13 | + | }; |
|
| 14 | + | use db::Db; |
|
| 15 | + | use reqwest::Client; |
|
| 16 | + | use serde::Deserialize; |
|
| 17 | + | use std::sync::{Arc, Mutex}; |
|
| 18 | + | use tower_http::services::ServeDir; |
|
| 19 | + | ||
| 20 | + | // ── App State ────────────────────────────────────────────────────────────── |
|
| 21 | + | ||
| 22 | + | pub struct AppState { |
|
| 23 | + | pub db: Db, |
|
| 24 | + | pub app_password: String, |
|
| 25 | + | pub cookie_secure: bool, |
|
| 26 | + | pub usps_token: Arc<Mutex<Option<usps::CachedToken>>>, |
|
| 27 | + | pub usps_client_id: String, |
|
| 28 | + | pub usps_client_secret: String, |
|
| 29 | + | pub http_client: Client, |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | // ── Template rendering helper ────────────────────────────────────────────── |
|
| 33 | + | ||
| 34 | + | fn render<T: Template>(t: T) -> Response { |
|
| 35 | + | match t.render() { |
|
| 36 | + | Ok(html) => Html(html).into_response(), |
|
| 37 | + | Err(e) => { |
|
| 38 | + | tracing::error!("Template render error: {}", e); |
|
| 39 | + | (StatusCode::INTERNAL_SERVER_ERROR, "Internal error.").into_response() |
|
| 40 | + | } |
|
| 41 | + | } |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | // ── Query params ─────────────────────────────────────────────────────────── |
|
| 45 | + | ||
| 46 | + | #[derive(Deserialize, Default)] |
|
| 47 | + | pub struct ErrorQuery { |
|
| 48 | + | pub error: Option<String>, |
|
| 49 | + | } |
|
| 50 | + | ||
| 51 | + | // ── Templates ────────────────────────────────────────────────────────────── |
|
| 52 | + | ||
| 53 | + | #[derive(Template)] |
|
| 54 | + | #[template(path = "login.html")] |
|
| 55 | + | struct LoginTemplate { |
|
| 56 | + | error: Option<String>, |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | #[derive(Template)] |
|
| 60 | + | #[template(path = "index.html")] |
|
| 61 | + | struct IndexTemplate { |
|
| 62 | + | packages: Vec<db::Package>, |
|
| 63 | + | error: Option<String>, |
|
| 64 | + | } |
|
| 65 | + | ||
| 66 | + | #[derive(Template)] |
|
| 67 | + | #[template(path = "add.html")] |
|
| 68 | + | struct AddTemplate { |
|
| 69 | + | error: Option<String>, |
|
| 70 | + | } |
|
| 71 | + | ||
| 72 | + | #[derive(Template)] |
|
| 73 | + | #[template(path = "detail.html")] |
|
| 74 | + | struct DetailTemplate { |
|
| 75 | + | package: db::Package, |
|
| 76 | + | events: Vec<db::TrackingEvent>, |
|
| 77 | + | error: Option<String>, |
|
| 78 | + | } |
|
| 79 | + | ||
| 80 | + | // ── Login ────────────────────────────────────────────────────────────────── |
|
| 81 | + | ||
| 82 | + | async fn get_login(Query(q): Query<ErrorQuery>) -> Response { |
|
| 83 | + | render(LoginTemplate { error: q.error }) |
|
| 84 | + | } |
|
| 85 | + | ||
| 86 | + | #[derive(Deserialize)] |
|
| 87 | + | struct LoginForm { |
|
| 88 | + | password: String, |
|
| 89 | + | } |
|
| 90 | + | ||
| 91 | + | async fn post_login( |
|
| 92 | + | State(state): State<Arc<AppState>>, |
|
| 93 | + | Form(form): Form<LoginForm>, |
|
| 94 | + | ) -> Response { |
|
| 95 | + | if !auth::verify_password(&form.password, &state.app_password) { |
|
| 96 | + | return render(LoginTemplate { error: Some("Invalid password.".to_string()) }); |
|
| 97 | + | } |
|
| 98 | + | ||
| 99 | + | let _ = db::prune_expired_sessions(&state.db); |
|
| 100 | + | ||
| 101 | + | let token = auth::generate_session_token(); |
|
| 102 | + | let expires_at = auth::session_expiry_at(); |
|
| 103 | + | ||
| 104 | + | if let Err(e) = db::insert_session(&state.db, &token, &expires_at) { |
|
| 105 | + | tracing::error!("Failed to insert session: {}", e); |
|
| 106 | + | return (StatusCode::INTERNAL_SERVER_ERROR, "Internal error.").into_response(); |
|
| 107 | + | } |
|
| 108 | + | ||
| 109 | + | let cookie = auth::build_session_cookie(&token, state.cookie_secure); |
|
| 110 | + | let mut resp = Redirect::to("/").into_response(); |
|
| 111 | + | resp.headers_mut().insert("set-cookie", cookie.parse().unwrap()); |
|
| 112 | + | resp |
|
| 113 | + | } |
|
| 114 | + | ||
| 115 | + | // ── Logout ───────────────────────────────────────────────────────────────── |
|
| 116 | + | ||
| 117 | + | async fn get_logout( |
|
| 118 | + | State(state): State<Arc<AppState>>, |
|
| 119 | + | headers: HeaderMap, |
|
| 120 | + | ) -> Response { |
|
| 121 | + | if let Some(token) = auth::extract_session_token(&headers) { |
|
| 122 | + | let _ = db::delete_session(&state.db, &token); |
|
| 123 | + | } |
|
| 124 | + | let mut resp = Redirect::to("/login").into_response(); |
|
| 125 | + | resp.headers_mut().insert("set-cookie", auth::clear_session_cookie().parse().unwrap()); |
|
| 126 | + | resp |
|
| 127 | + | } |
|
| 128 | + | ||
| 129 | + | // ── Refresh Helper ───────────────────────────────────────────────────────── |
|
| 130 | + | ||
| 131 | + | async fn refresh_one(state: &AppState, package: &db::Package) -> Result<(), anyhow::Error> { |
|
| 132 | + | let token = usps::get_token( |
|
| 133 | + | &state.usps_token, |
|
| 134 | + | &state.http_client, |
|
| 135 | + | &state.usps_client_id, |
|
| 136 | + | &state.usps_client_secret, |
|
| 137 | + | ) |
|
| 138 | + | .await?; |
|
| 139 | + | ||
| 140 | + | let detail = usps::fetch_tracking(&state.http_client, &token, &package.tracking_number).await?; |
|
| 141 | + | ||
| 142 | + | use std::time::{SystemTime, UNIX_EPOCH}; |
|
| 143 | + | let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); |
|
| 144 | + | let refreshed_at = { |
|
| 145 | + | let days = now / 86400; |
|
| 146 | + | let h = (now / 3600) % 24; |
|
| 147 | + | let m = (now / 60) % 60; |
|
| 148 | + | let s = now % 60; |
|
| 149 | + | auth::format_unix_to_datetime_pub(days, h, m, s) |
|
| 150 | + | }; |
|
| 151 | + | ||
| 152 | + | let expected_delivery = detail |
|
| 153 | + | .delivery_date_expectation |
|
| 154 | + | .as_ref() |
|
| 155 | + | .and_then(|d| d.expected_delivery_date.as_deref()); |
|
| 156 | + | ||
| 157 | + | db::update_package_status( |
|
| 158 | + | &state.db, |
|
| 159 | + | package.id, |
|
| 160 | + | detail.status.as_deref().unwrap_or(""), |
|
| 161 | + | detail.status_category.as_deref(), |
|
| 162 | + | detail.status_summary.as_deref(), |
|
| 163 | + | detail.mail_class.as_deref(), |
|
| 164 | + | expected_delivery, |
|
| 165 | + | &refreshed_at, |
|
| 166 | + | )?; |
|
| 167 | + | ||
| 168 | + | db::delete_events_for_package(&state.db, package.id)?; |
|
| 169 | + | ||
| 170 | + | for event in &detail.tracking_events { |
|
| 171 | + | if let Err(e) = db::insert_event( |
|
| 172 | + | &state.db, |
|
| 173 | + | package.id, |
|
| 174 | + | event.event_timestamp.as_deref(), |
|
| 175 | + | event.event_type.as_deref(), |
|
| 176 | + | event.event_city.as_deref(), |
|
| 177 | + | event.event_state.as_deref(), |
|
| 178 | + | event.event_zip_code.as_deref(), |
|
| 179 | + | event.event_code.as_deref(), |
|
| 180 | + | ) { |
|
| 181 | + | tracing::warn!("DB error inserting event for package {}: {}", package.id, e); |
|
| 182 | + | } |
|
| 183 | + | } |
|
| 184 | + | ||
| 185 | + | Ok(()) |
|
| 186 | + | } |
|
| 187 | + | ||
| 188 | + | // ── Index ────────────────────────────────────────────────────────────────── |
|
| 189 | + | ||
| 190 | + | async fn get_index( |
|
| 191 | + | _session: auth::AuthSession, |
|
| 192 | + | State(state): State<Arc<AppState>>, |
|
| 193 | + | Query(q): Query<ErrorQuery>, |
|
| 194 | + | ) -> Response { |
|
| 195 | + | let packages = match db::list_packages(&state.db) { |
|
| 196 | + | Ok(p) => p, |
|
| 197 | + | Err(e) => { |
|
| 198 | + | tracing::error!("DB error listing packages: {}", e); |
|
| 199 | + | return (StatusCode::INTERNAL_SERVER_ERROR, "Internal error.").into_response(); |
|
| 200 | + | } |
|
| 201 | + | }; |
|
| 202 | + | ||
| 203 | + | for package in &packages { |
|
| 204 | + | if let Err(e) = refresh_one(&state, package).await { |
|
| 205 | + | tracing::warn!("Failed to refresh package {}: {}", package.id, e); |
|
| 206 | + | } |
|
| 207 | + | } |
|
| 208 | + | ||
| 209 | + | match db::list_packages(&state.db) { |
|
| 210 | + | Ok(packages) => render(IndexTemplate { packages, error: q.error }), |
|
| 211 | + | Err(e) => { |
|
| 212 | + | tracing::error!("DB error listing packages after refresh: {}", e); |
|
| 213 | + | (StatusCode::INTERNAL_SERVER_ERROR, "Internal error.").into_response() |
|
| 214 | + | } |
|
| 215 | + | } |
|
| 216 | + | } |
|
| 217 | + | ||
| 218 | + | // ── Add Package ──────────────────────────────────────────────────────────── |
|
| 219 | + | ||
| 220 | + | async fn get_add( |
|
| 221 | + | _session: auth::AuthSession, |
|
| 222 | + | Query(q): Query<ErrorQuery>, |
|
| 223 | + | ) -> Response { |
|
| 224 | + | render(AddTemplate { error: q.error }) |
|
| 225 | + | } |
|
| 226 | + | ||
| 227 | + | #[derive(Deserialize)] |
|
| 228 | + | struct AddPackageForm { |
|
| 229 | + | tracking_number: String, |
|
| 230 | + | label: Option<String>, |
|
| 231 | + | } |
|
| 232 | + | ||
| 233 | + | async fn post_packages( |
|
| 234 | + | _session: auth::AuthSession, |
|
| 235 | + | State(state): State<Arc<AppState>>, |
|
| 236 | + | Form(form): Form<AddPackageForm>, |
|
| 237 | + | ) -> Response { |
|
| 238 | + | let tracking_number = form.tracking_number.trim().to_uppercase(); |
|
| 239 | + | if tracking_number.is_empty() { |
|
| 240 | + | return Redirect::to("/packages/add?error=Tracking+number+is+required.").into_response(); |
|
| 241 | + | } |
|
| 242 | + | let label = form.label.as_deref().map(str::trim).filter(|s| !s.is_empty()); |
|
| 243 | + | ||
| 244 | + | match db::insert_package(&state.db, &tracking_number, label) { |
|
| 245 | + | Ok(_) => Redirect::to("/").into_response(), |
|
| 246 | + | Err(e) if e.to_string().contains("UNIQUE") => { |
|
| 247 | + | Redirect::to("/packages/add?error=Tracking+number+already+exists.").into_response() |
|
| 248 | + | } |
|
| 249 | + | Err(e) => { |
|
| 250 | + | tracing::error!("DB error inserting package: {}", e); |
|
| 251 | + | (StatusCode::INTERNAL_SERVER_ERROR, "Internal error.").into_response() |
|
| 252 | + | } |
|
| 253 | + | } |
|
| 254 | + | } |
|
| 255 | + | ||
| 256 | + | // ── Delete Package ───────────────────────────────────────────────────────── |
|
| 257 | + | ||
| 258 | + | async fn post_delete_package( |
|
| 259 | + | _session: auth::AuthSession, |
|
| 260 | + | State(state): State<Arc<AppState>>, |
|
| 261 | + | Path(id): Path<i64>, |
|
| 262 | + | ) -> Response { |
|
| 263 | + | if let Err(e) = db::delete_package(&state.db, id) { |
|
| 264 | + | tracing::error!("DB error deleting package {}: {}", id, e); |
|
| 265 | + | return (StatusCode::INTERNAL_SERVER_ERROR, "Internal error.").into_response(); |
|
| 266 | + | } |
|
| 267 | + | Redirect::to("/").into_response() |
|
| 268 | + | } |
|
| 269 | + | ||
| 270 | + | // ── Refresh Package ──────────────────────────────────────────────────────── |
|
| 271 | + | ||
| 272 | + | async fn post_refresh_package( |
|
| 273 | + | _session: auth::AuthSession, |
|
| 274 | + | State(state): State<Arc<AppState>>, |
|
| 275 | + | Path(id): Path<i64>, |
|
| 276 | + | ) -> Response { |
|
| 277 | + | let package = match db::get_package(&state.db, id) { |
|
| 278 | + | Ok(Some(p)) => p, |
|
| 279 | + | Ok(None) => return Redirect::to("/?error=Package+not+found.").into_response(), |
|
| 280 | + | Err(e) => { |
|
| 281 | + | tracing::error!("DB error fetching package {}: {}", id, e); |
|
| 282 | + | return (StatusCode::INTERNAL_SERVER_ERROR, "Internal error.").into_response(); |
|
| 283 | + | } |
|
| 284 | + | }; |
|
| 285 | + | ||
| 286 | + | if let Err(e) = refresh_one(&state, &package).await { |
|
| 287 | + | let msg = urlencoding_encode(&e.to_string()); |
|
| 288 | + | return Redirect::to(&format!("/packages/{}?error={}", id, msg)).into_response(); |
|
| 289 | + | } |
|
| 290 | + | ||
| 291 | + | Redirect::to(&format!("/packages/{}", id)).into_response() |
|
| 292 | + | } |
|
| 293 | + | ||
| 294 | + | // ── Package Detail ───────────────────────────────────────────────────────── |
|
| 295 | + | ||
| 296 | + | async fn get_package_detail( |
|
| 297 | + | _session: auth::AuthSession, |
|
| 298 | + | State(state): State<Arc<AppState>>, |
|
| 299 | + | Path(id): Path<i64>, |
|
| 300 | + | Query(q): Query<ErrorQuery>, |
|
| 301 | + | ) -> Response { |
|
| 302 | + | let package = match db::get_package(&state.db, id) { |
|
| 303 | + | Ok(Some(p)) => p, |
|
| 304 | + | Ok(None) => return Redirect::to("/?error=Package+not+found.").into_response(), |
|
| 305 | + | Err(e) => { |
|
| 306 | + | tracing::error!("DB error fetching package {}: {}", id, e); |
|
| 307 | + | return (StatusCode::INTERNAL_SERVER_ERROR, "Internal error.").into_response(); |
|
| 308 | + | } |
|
| 309 | + | }; |
|
| 310 | + | ||
| 311 | + | let events = match db::get_events_for_package(&state.db, id) { |
|
| 312 | + | Ok(e) => e, |
|
| 313 | + | Err(e) => { |
|
| 314 | + | tracing::error!("DB error fetching events for package {}: {}", id, e); |
|
| 315 | + | vec![] |
|
| 316 | + | } |
|
| 317 | + | }; |
|
| 318 | + | ||
| 319 | + | render(DetailTemplate { package, events, error: q.error }) |
|
| 320 | + | } |
|
| 321 | + | ||
| 322 | + | // ── URL encoding helper ──────────────────────────────────────────────────── |
|
| 323 | + | ||
| 324 | + | fn urlencoding_encode(s: &str) -> String { |
|
| 325 | + | s.chars() |
|
| 326 | + | .flat_map(|c| match c { |
|
| 327 | + | ' ' => vec!['+'], |
|
| 328 | + | c if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '~' => vec![c], |
|
| 329 | + | c => { |
|
| 330 | + | let mut buf = [0u8; 4]; |
|
| 331 | + | let bytes = c.encode_utf8(&mut buf); |
|
| 332 | + | bytes.bytes().flat_map(|b| { |
|
| 333 | + | vec!['%', char::from_digit((b >> 4) as u32, 16).unwrap().to_ascii_uppercase(), |
|
| 334 | + | char::from_digit((b & 0xf) as u32, 16).unwrap().to_ascii_uppercase()] |
|
| 335 | + | }).collect() |
|
| 336 | + | } |
|
| 337 | + | }) |
|
| 338 | + | .collect() |
|
| 339 | + | } |
|
| 340 | + | ||
| 341 | + | // ── Main ─────────────────────────────────────────────────────────────────── |
|
| 342 | + | ||
| 343 | + | #[tokio::main] |
|
| 344 | + | async fn main() { |
|
| 345 | + | tracing_subscriber::fmt::init(); |
|
| 346 | + | use std::env; |
|
| 347 | + | ||
| 348 | + | let database_url = db::database_path(); |
|
| 349 | + | let app_password = env::var("APP_PASSWORD").expect("APP_PASSWORD must be set"); |
|
| 350 | + | let usps_client_id = env::var("USPS_CLIENT_ID").expect("USPS_CLIENT_ID must be set"); |
|
| 351 | + | let usps_client_secret = env::var("USPS_CLIENT_SECRET").expect("USPS_CLIENT_SECRET must be set"); |
|
| 352 | + | let bind_addr = env::var("BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:3012".to_string()); |
|
| 353 | + | let cookie_secure = env::var("COOKIE_SECURE") |
|
| 354 | + | .map(|v| v.eq_ignore_ascii_case("true")) |
|
| 355 | + | .unwrap_or(false); |
|
| 356 | + | ||
| 357 | + | let db = db::init_db(&database_url).expect("Failed to open database"); |
|
| 358 | + | ||
| 359 | + | let state = Arc::new(AppState { |
|
| 360 | + | db, |
|
| 361 | + | app_password, |
|
| 362 | + | cookie_secure, |
|
| 363 | + | usps_token: Arc::new(Mutex::new(None)), |
|
| 364 | + | usps_client_id, |
|
| 365 | + | usps_client_secret, |
|
| 366 | + | http_client: Client::new(), |
|
| 367 | + | }); |
|
| 368 | + | ||
| 369 | + | let app = Router::new() |
|
| 370 | + | .route("/login", get(get_login).post(post_login)) |
|
| 371 | + | .route("/logout", get(get_logout)) |
|
| 372 | + | .route("/", get(get_index)) |
|
| 373 | + | .route("/packages/add", get(get_add)) |
|
| 374 | + | .route("/packages", post(post_packages)) |
|
| 375 | + | .route("/packages/{id}", get(get_package_detail)) |
|
| 376 | + | .route("/packages/{id}/refresh", post(post_refresh_package)) |
|
| 377 | + | .route("/packages/{id}/delete", post(post_delete_package)) |
|
| 378 | + | .nest_service("/static", ServeDir::new("static")) |
|
| 379 | + | .with_state(state); |
|
| 380 | + | ||
| 381 | + | let listener = tokio::net::TcpListener::bind(&bind_addr) |
|
| 382 | + | .await |
|
| 383 | + | .expect("Failed to bind"); |
|
| 384 | + | eprintln!("Listening on {}", bind_addr); |
|
| 385 | + | axum::serve(listener, app).await.expect("Server failed"); |
|
| 386 | + | } |
| 1 | + | use reqwest::Client; |
|
| 2 | + | use serde::{Deserialize, Serialize}; |
|
| 3 | + | use std::time::{Duration, Instant}; |
|
| 4 | + | ||
| 5 | + | // ── Token Cache ──────────────────────────────────────────────────────────── |
|
| 6 | + | ||
| 7 | + | pub struct CachedToken { |
|
| 8 | + | pub token: String, |
|
| 9 | + | pub expires_at: Instant, |
|
| 10 | + | } |
|
| 11 | + | ||
| 12 | + | // ── USPS OAuth2 Response ─────────────────────────────────────────────────── |
|
| 13 | + | ||
| 14 | + | #[derive(Deserialize)] |
|
| 15 | + | struct TokenResponse { |
|
| 16 | + | access_token: String, |
|
| 17 | + | expires_in: u64, |
|
| 18 | + | } |
|
| 19 | + | ||
| 20 | + | // ── Tracking Request/Response Types ──────────────────────────────────────── |
|
| 21 | + | ||
| 22 | + | #[derive(Serialize)] |
|
| 23 | + | struct TrackingRequestBody { |
|
| 24 | + | #[serde(rename = "trackingNumber")] |
|
| 25 | + | tracking_number: String, |
|
| 26 | + | } |
|
| 27 | + | ||
| 28 | + | #[derive(Debug, Deserialize)] |
|
| 29 | + | #[serde(rename_all = "camelCase")] |
|
| 30 | + | pub struct TrackingDetail { |
|
| 31 | + | pub tracking_number: Option<String>, |
|
| 32 | + | pub status: Option<String>, |
|
| 33 | + | pub status_category: Option<String>, |
|
| 34 | + | pub status_summary: Option<String>, |
|
| 35 | + | pub mail_class: Option<String>, |
|
| 36 | + | pub delivery_date_expectation: Option<DeliveryDateExpectation>, |
|
| 37 | + | #[serde(default)] |
|
| 38 | + | pub tracking_events: Vec<TrackingEvent>, |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | #[derive(Debug, Deserialize)] |
|
| 42 | + | #[serde(rename_all = "camelCase")] |
|
| 43 | + | pub struct DeliveryDateExpectation { |
|
| 44 | + | pub expected_delivery_date: Option<String>, |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | #[derive(Debug, Deserialize)] |
|
| 48 | + | #[serde(rename_all = "camelCase")] |
|
| 49 | + | pub struct TrackingEvent { |
|
| 50 | + | pub event_timestamp: Option<String>, |
|
| 51 | + | pub event_type: Option<String>, |
|
| 52 | + | pub event_city: Option<String>, |
|
| 53 | + | pub event_state: Option<String>, |
|
| 54 | + | #[serde(rename = "eventZIPCode")] |
|
| 55 | + | pub event_zip_code: Option<String>, |
|
| 56 | + | pub event_code: Option<String>, |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | // ── Multi-status (207) response ──────────────────────────────────────────── |
|
| 60 | + | ||
| 61 | + | #[derive(Debug, Deserialize)] |
|
| 62 | + | #[serde(rename_all = "camelCase")] |
|
| 63 | + | pub struct FailureResponse { |
|
| 64 | + | #[serde(default)] |
|
| 65 | + | pub status_code: String, |
|
| 66 | + | pub error: Option<ErrorObject>, |
|
| 67 | + | } |
|
| 68 | + | ||
| 69 | + | #[derive(Debug, Deserialize)] |
|
| 70 | + | pub struct ErrorObject { |
|
| 71 | + | pub message: Option<String>, |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | // ── Errors ───────────────────────────────────────────────────────────────── |
|
| 75 | + | ||
| 76 | + | #[derive(Debug)] |
|
| 77 | + | pub enum UspsError { |
|
| 78 | + | NotFoundOrInvalid(String), |
|
| 79 | + | BadRequest, |
|
| 80 | + | Unauthorized, |
|
| 81 | + | RateLimit, |
|
| 82 | + | ServiceUnavailable, |
|
| 83 | + | Timeout, |
|
| 84 | + | Other(String), |
|
| 85 | + | } |
|
| 86 | + | ||
| 87 | + | impl std::fmt::Display for UspsError { |
|
| 88 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
|
| 89 | + | match self { |
|
| 90 | + | UspsError::NotFoundOrInvalid(msg) => write!(f, "{}", msg), |
|
| 91 | + | UspsError::BadRequest => write!(f, "Invalid request sent to USPS API. Check the tracking number format."), |
|
| 92 | + | UspsError::Unauthorized => write!(f, "USPS credentials are invalid. Check USPS_CLIENT_ID and USPS_CLIENT_SECRET."), |
|
| 93 | + | UspsError::RateLimit => write!(f, "USPS rate limit hit. Try again shortly."), |
|
| 94 | + | UspsError::ServiceUnavailable => write!(f, "USPS service unavailable. Try again later."), |
|
| 95 | + | UspsError::Timeout => write!(f, "USPS request timed out."), |
|
| 96 | + | UspsError::Other(msg) => write!(f, "{}", msg), |
|
| 97 | + | } |
|
| 98 | + | } |
|
| 99 | + | } |
|
| 100 | + | ||
| 101 | + | impl std::error::Error for UspsError {} |
|
| 102 | + | ||
| 103 | + | // ── Token Fetch ──────────────────────────────────────────────────────────── |
|
| 104 | + | ||
| 105 | + | pub async fn fetch_token( |
|
| 106 | + | client: &Client, |
|
| 107 | + | client_id: &str, |
|
| 108 | + | client_secret: &str, |
|
| 109 | + | ) -> Result<CachedToken, UspsError> { |
|
| 110 | + | let params = [ |
|
| 111 | + | ("grant_type", "client_credentials"), |
|
| 112 | + | ("client_id", client_id), |
|
| 113 | + | ("client_secret", client_secret), |
|
| 114 | + | ("scope", "tracking"), |
|
| 115 | + | ]; |
|
| 116 | + | ||
| 117 | + | let resp = client |
|
| 118 | + | .post("https://apis.usps.com/oauth2/v3/token") |
|
| 119 | + | .form(¶ms) |
|
| 120 | + | .timeout(Duration::from_secs(10)) |
|
| 121 | + | .send() |
|
| 122 | + | .await |
|
| 123 | + | .map_err(|e| { |
|
| 124 | + | if e.is_timeout() { |
|
| 125 | + | UspsError::Timeout |
|
| 126 | + | } else { |
|
| 127 | + | UspsError::Other(e.to_string()) |
|
| 128 | + | } |
|
| 129 | + | })?; |
|
| 130 | + | ||
| 131 | + | match resp.status().as_u16() { |
|
| 132 | + | 200 => { |
|
| 133 | + | let body: TokenResponse = resp.json().await.map_err(|e| UspsError::Other(e.to_string()))?; |
|
| 134 | + | let expires_at = Instant::now() + Duration::from_secs(body.expires_in.saturating_sub(30)); |
|
| 135 | + | Ok(CachedToken { token: body.access_token, expires_at }) |
|
| 136 | + | } |
|
| 137 | + | 401 => Err(UspsError::Unauthorized), |
|
| 138 | + | 429 => Err(UspsError::RateLimit), |
|
| 139 | + | 503 => Err(UspsError::ServiceUnavailable), |
|
| 140 | + | _ => Err(UspsError::Other(format!("OAuth token request failed: {}", resp.status()))), |
|
| 141 | + | } |
|
| 142 | + | } |
|
| 143 | + | ||
| 144 | + | // ── Token Cache Helper ───────────────────────────────────────────────────── |
|
| 145 | + | ||
| 146 | + | /// Get or refresh the cached USPS token. |
|
| 147 | + | pub async fn get_token( |
|
| 148 | + | cache: &std::sync::Arc<std::sync::Mutex<Option<CachedToken>>>, |
|
| 149 | + | client: &Client, |
|
| 150 | + | client_id: &str, |
|
| 151 | + | client_secret: &str, |
|
| 152 | + | ) -> Result<String, UspsError> { |
|
| 153 | + | { |
|
| 154 | + | let guard = cache.lock().unwrap(); |
|
| 155 | + | if let Some(ref cached) = *guard { |
|
| 156 | + | if Instant::now() < cached.expires_at { |
|
| 157 | + | return Ok(cached.token.clone()); |
|
| 158 | + | } |
|
| 159 | + | } |
|
| 160 | + | } |
|
| 161 | + | // Need to fetch or refresh |
|
| 162 | + | let new_token = fetch_token(client, client_id, client_secret).await?; |
|
| 163 | + | let token_str = new_token.token.clone(); |
|
| 164 | + | let mut guard = cache.lock().unwrap(); |
|
| 165 | + | *guard = Some(new_token); |
|
| 166 | + | Ok(token_str) |
|
| 167 | + | } |
|
| 168 | + | ||
| 169 | + | // ── Tracking Request ─────────────────────────────────────────────────────── |
|
| 170 | + | ||
| 171 | + | pub async fn fetch_tracking( |
|
| 172 | + | client: &Client, |
|
| 173 | + | token: &str, |
|
| 174 | + | tracking_number: &str, |
|
| 175 | + | ) -> Result<TrackingDetail, UspsError> { |
|
| 176 | + | let body = vec![TrackingRequestBody { |
|
| 177 | + | tracking_number: tracking_number.to_string(), |
|
| 178 | + | }]; |
|
| 179 | + | ||
| 180 | + | let resp = client |
|
| 181 | + | .post("https://apis.usps.com/tracking/v3r2/tracking") |
|
| 182 | + | .bearer_auth(token) |
|
| 183 | + | .json(&body) |
|
| 184 | + | .timeout(Duration::from_secs(10)) |
|
| 185 | + | .send() |
|
| 186 | + | .await |
|
| 187 | + | .map_err(|e| { |
|
| 188 | + | if e.is_timeout() { |
|
| 189 | + | UspsError::Timeout |
|
| 190 | + | } else { |
|
| 191 | + | UspsError::Other(e.to_string()) |
|
| 192 | + | } |
|
| 193 | + | })?; |
|
| 194 | + | ||
| 195 | + | match resp.status().as_u16() { |
|
| 196 | + | 200 => { |
|
| 197 | + | let mut details: Vec<TrackingDetail> = resp |
|
| 198 | + | .json() |
|
| 199 | + | .await |
|
| 200 | + | .map_err(|e| UspsError::Other(e.to_string()))?; |
|
| 201 | + | details |
|
| 202 | + | .pop() |
|
| 203 | + | .ok_or_else(|| UspsError::Other("Empty tracking response".into())) |
|
| 204 | + | } |
|
| 205 | + | 207 => { |
|
| 206 | + | // Try to extract error message from FailureResponse |
|
| 207 | + | let failures: Vec<FailureResponse> = resp |
|
| 208 | + | .json() |
|
| 209 | + | .await |
|
| 210 | + | .unwrap_or_default(); |
|
| 211 | + | let msg = failures |
|
| 212 | + | .into_iter() |
|
| 213 | + | .next() |
|
| 214 | + | .and_then(|f| f.error) |
|
| 215 | + | .and_then(|e| e.message) |
|
| 216 | + | .unwrap_or_else(|| "Tracking number not found or invalid.".to_string()); |
|
| 217 | + | Err(UspsError::NotFoundOrInvalid(msg)) |
|
| 218 | + | } |
|
| 219 | + | 400 => Err(UspsError::BadRequest), |
|
| 220 | + | 401 => Err(UspsError::Unauthorized), |
|
| 221 | + | 429 => Err(UspsError::RateLimit), |
|
| 222 | + | 503 => Err(UspsError::ServiceUnavailable), |
|
| 223 | + | _ => Err(UspsError::Other(format!("USPS tracking returned: {}", resp.status()))), |
|
| 224 | + | } |
|
| 225 | + | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | + | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}PARCELS — add package{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | <div class="header"> |
|
| 5 | + | <h1><a href="/" style="text-decoration:none;">PARCELS</a></h1> |
|
| 6 | + | </div> |
|
| 7 | + | {% if let Some(err) = error %} |
|
| 8 | + | <div class="error">{{ err }}</div> |
|
| 9 | + | {% endif %} |
|
| 10 | + | <form method="POST" action="/packages" style="max-width: 400px;"> |
|
| 11 | + | <div class="form-group"> |
|
| 12 | + | <label for="tracking_number">tracking number</label> |
|
| 13 | + | <input type="text" id="tracking_number" name="tracking_number" required autofocus |
|
| 14 | + | placeholder="e.g. 9400111899223397992148"> |
|
| 15 | + | </div> |
|
| 16 | + | <div class="form-group"> |
|
| 17 | + | <label for="label">label</label> |
|
| 18 | + | <input type="text" id="label" name="label" required placeholder="e.g. Amazon order"> |
|
| 19 | + | </div> |
|
| 20 | + | <button type="submit">add package</button> |
|
| 21 | + | </form> |
|
| 22 | + | {% endblock %} |
| 1 | + | <!DOCTYPE html> |
|
| 2 | + | <html lang="en"> |
|
| 3 | + | <head> |
|
| 4 | + | <meta charset="UTF-8"> |
|
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
| 6 | + | <title>{% block title %}PARCELS{% endblock %}</title> |
|
| 7 | + | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 8 | + | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 9 | + | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 10 | + | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 11 | + | <link rel="icon" href="/static/favicon.ico"> |
|
| 12 | + | <meta property="og:title" content="PARCELS"> |
|
| 13 | + | <meta property="og:image" content="/static/og.png"> |
|
| 14 | + | <meta property="og:type" content="website"> |
|
| 15 | + | <style> |
|
| 16 | + | @font-face { |
|
| 17 | + | font-family: 'CommitMono'; |
|
| 18 | + | src: url('/static/assets/fonts/CommitMono-400-Regular.otf') format('opentype'); |
|
| 19 | + | font-weight: 400; |
|
| 20 | + | } |
|
| 21 | + | @font-face { |
|
| 22 | + | font-family: 'CommitMono'; |
|
| 23 | + | src: url('/static/assets/fonts/CommitMono-700-Regular.otf') format('opentype'); |
|
| 24 | + | font-weight: 700; |
|
| 25 | + | } |
|
| 26 | + | *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
|
| 27 | + | body { |
|
| 28 | + | background: #121113; |
|
| 29 | + | color: #ffffff; |
|
| 30 | + | font-family: 'CommitMono', monospace; |
|
| 31 | + | font-size: 14px; |
|
| 32 | + | line-height: 1.6; |
|
| 33 | + | min-height: 100vh; |
|
| 34 | + | max-width: 700px; |
|
| 35 | + | margin: auto; |
|
| 36 | + | padding: 0 1rem; |
|
| 37 | + | display: flex; |
|
| 38 | + | flex-direction: column; |
|
| 39 | + | gap: 1.5rem; |
|
| 40 | + | } |
|
| 41 | + | .container { padding: 0; } |
|
| 42 | + | a { color: #ffffff; text-decoration: none; } |
|
| 43 | + | a:hover { opacity: 0.7; } |
|
| 44 | + | input, button { |
|
| 45 | + | font-family: 'CommitMono', monospace; |
|
| 46 | + | font-size: 14px; |
|
| 47 | + | background: #121113; |
|
| 48 | + | color: #ffffff; |
|
| 49 | + | border: 1px solid #ffffff; |
|
| 50 | + | padding: 0.4rem 0.75rem; |
|
| 51 | + | border-radius: 0; |
|
| 52 | + | cursor: pointer; |
|
| 53 | + | } |
|
| 54 | + | button:hover { opacity: 0.7; } |
|
| 55 | + | input { width: 100%; } |
|
| 56 | + | .error { |
|
| 57 | + | color: #ffffff; |
|
| 58 | + | border-left: 2px solid #ffffff; |
|
| 59 | + | padding-left: 0.5rem; |
|
| 60 | + | font-size: 13px; |
|
| 61 | + | opacity: 0.8; |
|
| 62 | + | } |
|
| 63 | + | table { width: 100%; border-collapse: collapse; } |
|
| 64 | + | th, td { |
|
| 65 | + | text-align: left; |
|
| 66 | + | padding: 0.4rem 0.5rem; |
|
| 67 | + | border-bottom: 1px solid #333; |
|
| 68 | + | vertical-align: top; |
|
| 69 | + | } |
|
| 70 | + | th { opacity: 0.5; font-weight: 400; font-size: 12px; text-transform: uppercase; } |
|
| 71 | + | .header { |
|
| 72 | + | display: flex; |
|
| 73 | + | flex-direction: column; |
|
| 74 | + | gap: 0.5rem; |
|
| 75 | + | margin-top: 2rem; |
|
| 76 | + | border-bottom: 1px solid #333; |
|
| 77 | + | padding-bottom: 1rem; |
|
| 78 | + | } |
|
| 79 | + | .links { display: flex; gap: 0.75rem; font-size: 12px; } |
|
| 80 | + | .form-group { margin-bottom: 1rem; } |
|
| 81 | + | .form-group label { display: block; margin-bottom: 0.25rem; opacity: 0.7; font-size: 12px; } |
|
| 82 | + | .null { opacity: 0.3; } |
|
| 83 | + | .status-delivered { /* no color accent per design */ } |
|
| 84 | + | .status-alert { opacity: 0.8; } |
|
| 85 | + | .actions { display: flex; gap: 0.5rem; } |
|
| 86 | + | .parcel-list { display: flex; flex-direction: column; width: 100%; } |
|
| 87 | + | .parcel-list a { text-decoration: none; } |
|
| 88 | + | .parcel-item { |
|
| 89 | + | display: flex; |
|
| 90 | + | flex-direction: column; |
|
| 91 | + | gap: 0.25rem; |
|
| 92 | + | padding: 0.75rem 0; |
|
| 93 | + | border-bottom: 1px solid #333; |
|
| 94 | + | } |
|
| 95 | + | .parcel-item:last-child { border-bottom: none; } |
|
| 96 | + | .parcel-label { font-size: 16px; color: #fff; } |
|
| 97 | + | .parcel-tracking { font-size: 12px; color: #888; } |
|
| 98 | + | .parcel-meta { font-size: 12px; color: #888; font-style: italic; } |
|
| 99 | + | </style> |
|
| 100 | + | </head> |
|
| 101 | + | <body> |
|
| 102 | + | <div class="container"> |
|
| 103 | + | {% block content %}{% endblock %} |
|
| 104 | + | </div> |
|
| 105 | + | </body> |
|
| 106 | + | </html> |
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}PARCELS — {% if let Some(label) = package.label %}{{ label }}{% else %}{{ package.tracking_number }}{% endif %}{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | <div class="header"> |
|
| 5 | + | <h1><a href="/" style="text-decoration:none;">PARCELS</a></h1> |
|
| 6 | + | <nav> |
|
| 7 | + | <a href="/">← back</a> |
|
| 8 | + | </nav> |
|
| 9 | + | </div> |
|
| 10 | + | {% if let Some(err) = error %} |
|
| 11 | + | <div class="error">{{ err }}</div> |
|
| 12 | + | {% endif %} |
|
| 13 | + | ||
| 14 | + | <div style="margin-bottom: 2rem;"> |
|
| 15 | + | <div style="display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 0.25rem;"> |
|
| 16 | + | {% if let Some(label) = package.label %} |
|
| 17 | + | <div style="font-size: 16px; font-weight: 700;">{{ label }}</div> |
|
| 18 | + | {% endif %} |
|
| 19 | + | <form method="POST" action="/packages/{{ package.id }}/delete" |
|
| 20 | + | onsubmit="return confirm('Delete this package?')"> |
|
| 21 | + | <button type="submit" style="font-size: 12px; opacity: 0.6; border-color: #555; margin-top: 0.5rem;">delete</button> |
|
| 22 | + | </form> |
|
| 23 | + | </div> |
|
| 24 | + | <div style="opacity: 0.6; margin-bottom: 1rem;">{{ package.tracking_number }}</div> |
|
| 25 | + | ||
| 26 | + | <table style="width: auto; margin-bottom: 1.5rem;"> |
|
| 27 | + | <tr> |
|
| 28 | + | <th style="padding-right: 2rem;">status</th> |
|
| 29 | + | <td>{% if let Some(s) = package.status %}{{ s }}{% else %}<span class="null">—</span>{% endif %}</td> |
|
| 30 | + | </tr> |
|
| 31 | + | <tr> |
|
| 32 | + | <th>summary</th> |
|
| 33 | + | <td>{% if let Some(s) = package.status_summary %}{{ s }}{% else %}<span class="null">—</span>{% endif %}</td> |
|
| 34 | + | </tr> |
|
| 35 | + | <tr> |
|
| 36 | + | <th>mail class</th> |
|
| 37 | + | <td>{% if let Some(m) = package.mail_class %}{{ m }}{% else %}<span class="null">—</span>{% endif %}</td> |
|
| 38 | + | </tr> |
|
| 39 | + | <tr> |
|
| 40 | + | <th>expected delivery</th> |
|
| 41 | + | <td>{% if let Some(d) = package.expected_delivery %}{{ d }}{% else %}<span class="null">—</span>{% endif %}</td> |
|
| 42 | + | </tr> |
|
| 43 | + | </table> |
|
| 44 | + | ||
| 45 | + | </div> |
|
| 46 | + | ||
| 47 | + | {% if !events.is_empty() %} |
|
| 48 | + | <div> |
|
| 49 | + | <div style="opacity: 0.5; font-size: 12px; text-transform: uppercase; margin-bottom: 1rem; border-bottom: 1px solid #333; padding-bottom: 0.5rem;"> |
|
| 50 | + | event history |
|
| 51 | + | </div> |
|
| 52 | + | {% for event in events %} |
|
| 53 | + | <div style="padding: 0.75rem 0; border-bottom: 1px solid #1e1c1f;"> |
|
| 54 | + | <div style="opacity: 0.5; font-size: 12px; margin-bottom: 0.2rem;"> |
|
| 55 | + | {% if let Some(ts) = event.event_timestamp %}{{ ts }}{% else %}<span class="null">—</span>{% endif %} |
|
| 56 | + | </div> |
|
| 57 | + | <div style="font-weight: 700;"> |
|
| 58 | + | {% if let Some(et) = event.event_type %}{{ et }}{% else %}<span class="null">—</span>{% endif %} |
|
| 59 | + | </div> |
|
| 60 | + | {% if event.event_city.is_some() || event.event_state.is_some() %} |
|
| 61 | + | <div style="opacity: 0.6; font-size: 13px;"> |
|
| 62 | + | {% if let Some(city) = event.event_city %}{{ city }}{% endif %}{% if event.event_city.is_some() && event.event_state.is_some() %}, {% endif %}{% if let Some(state) = event.event_state %}{{ state }}{% endif %} |
|
| 63 | + | </div> |
|
| 64 | + | {% endif %} |
|
| 65 | + | </div> |
|
| 66 | + | {% endfor %} |
|
| 67 | + | </div> |
|
| 68 | + | {% else %} |
|
| 69 | + | <p style="opacity: 0.4;">no events yet. click refresh to load tracking history.</p> |
|
| 70 | + | {% endif %} |
|
| 71 | + | {% endblock %} |
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}PARCELS{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | <div class="header"> |
|
| 5 | + | <h1>PARCELS</h1> |
|
| 6 | + | <div class="links"> |
|
| 7 | + | <a href="/packages/add">add package</a> |
|
| 8 | + | </div> |
|
| 9 | + | </div> |
|
| 10 | + | {% if let Some(err) = error %} |
|
| 11 | + | <div class="error">{{ err }}</div> |
|
| 12 | + | {% endif %} |
|
| 13 | + | {% if packages.is_empty() %} |
|
| 14 | + | <p style="opacity: 0.4;">no packages. <a href="/packages/add">add one</a></p> |
|
| 15 | + | {% else %} |
|
| 16 | + | <div class="parcel-list"> |
|
| 17 | + | {% for pkg in packages %} |
|
| 18 | + | <a href="/packages/{{ pkg.id }}"> |
|
| 19 | + | <div class="parcel-item"> |
|
| 20 | + | {% if let Some(label) = pkg.label %} |
|
| 21 | + | <span class="parcel-label">{{ label }}</span> |
|
| 22 | + | <span class="parcel-tracking">{{ pkg.tracking_number }}</span> |
|
| 23 | + | {% else %} |
|
| 24 | + | <span class="parcel-label">{{ pkg.tracking_number }}</span> |
|
| 25 | + | {% endif %} |
|
| 26 | + | {% if let Some(summary) = pkg.status_summary %} |
|
| 27 | + | <span class="parcel-meta">{{ summary }}</span> |
|
| 28 | + | {% else if let Some(status) = pkg.status %} |
|
| 29 | + | <span class="parcel-meta">{{ status }}</span> |
|
| 30 | + | {% endif %} |
|
| 31 | + | </div> |
|
| 32 | + | </a> |
|
| 33 | + | {% endfor %} |
|
| 34 | + | </div> |
|
| 35 | + | {% endif %} |
|
| 36 | + | {% endblock %} |
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}PARCELS — login{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | <div style="max-width: 320px; margin: 4rem auto;"> |
|
| 5 | + | <h1 style="font-size: 24px; margin-bottom: 2rem; font-weight: 700;">PARCELS</h1> |
|
| 6 | + | {% if let Some(err) = error %} |
|
| 7 | + | <div class="error">{{ err }}</div> |
|
| 8 | + | {% endif %} |
|
| 9 | + | <form method="POST" action="/login"> |
|
| 10 | + | <div class="form-group"> |
|
| 11 | + | <label for="password">password</label> |
|
| 12 | + | <input type="password" id="password" name="password" autofocus required> |
|
| 13 | + | </div> |
|
| 14 | + | <button type="submit" style="width: 100%; margin-top: 0.5rem;">sign in</button> |
|
| 15 | + | </form> |
|
| 16 | + | </div> |
|
| 17 | + | {% endblock %} |
| 1 | + | [package] |
|
| 2 | + | name = "shrink" |
|
| 3 | + | version = "0.1.0" |
|
| 4 | + | edition = "2024" |
|
| 5 | + | ||
| 6 | + | [dependencies] |
|
| 7 | + | axum = { workspace = true, features = ["multipart"] } |
|
| 8 | + | tokio = { workspace = true } |
|
| 9 | + | serde = { workspace = true } |
|
| 10 | + | tower-http = { workspace = true, features = ["fs"] } |
|
| 11 | + | tracing = { workspace = true } |
|
| 12 | + | tracing-subscriber = { workspace = true } |
|
| 13 | + | askama = "0.15" |
|
| 14 | + | image = "0.25" |
| 1 | + | # Build from repo root: docker build -t shrink -f apps/shrink/Dockerfile . |
|
| 2 | + | FROM rust:1-slim-bookworm AS builder |
|
| 3 | + | WORKDIR /app |
|
| 4 | + | ||
| 5 | + | # Copy workspace manifests |
|
| 6 | + | COPY Cargo.toml Cargo.lock ./ |
|
| 7 | + | COPY crates/auth/Cargo.toml crates/auth/ |
|
| 8 | + | COPY apps/sipp/Cargo.toml apps/sipp/ |
|
| 9 | + | COPY apps/feeds/Cargo.toml apps/feeds/ |
|
| 10 | + | COPY apps/parcels/Cargo.toml apps/parcels/ |
|
| 11 | + | COPY apps/jotts/Cargo.toml apps/jotts/ |
|
| 12 | + | COPY apps/og/Cargo.toml apps/og/ |
|
| 13 | + | COPY apps/shrink/Cargo.toml apps/shrink/ |
|
| 14 | + | ||
| 15 | + | # Create stubs for dependency caching |
|
| 16 | + | RUN mkdir -p crates/auth/src && echo '' > crates/auth/src/lib.rs \ |
|
| 17 | + | && for app in sipp feeds parcels jotts og shrink; do \ |
|
| 18 | + | mkdir -p apps/$app/src && echo 'fn main() {}' > apps/$app/src/main.rs; \ |
|
| 19 | + | done |
|
| 20 | + | ||
| 21 | + | RUN cargo build --release -p shrink |
|
| 22 | + | ||
| 23 | + | # Copy real source |
|
| 24 | + | COPY apps/shrink/src apps/shrink/src |
|
| 25 | + | COPY apps/shrink/templates apps/shrink/templates |
|
| 26 | + | COPY apps/shrink/static apps/shrink/static |
|
| 27 | + | ||
| 28 | + | RUN touch apps/shrink/src/*.rs && cargo build --release -p shrink |
|
| 29 | + | ||
| 30 | + | FROM debian:bookworm-slim |
|
| 31 | + | WORKDIR /app |
|
| 32 | + | COPY --from=builder /app/target/release/shrink /usr/local/bin/shrink |
|
| 33 | + | COPY --from=builder /app/apps/shrink/static /app/static |
|
| 34 | + | EXPOSE 3000 |
|
| 35 | + | ENV HOST=0.0.0.0 |
|
| 36 | + | CMD ["shrink"] |
| 1 | + | MIT License |
|
| 2 | + | ||
| 3 | + | Copyright (c) 2026 Steve Simkins |
|
| 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 | + |
| 1 | + | # Shrink |
|
| 2 | + | ||
| 3 | + |  |
|
| 4 | + | ||
| 5 | + | A minimal image compression app |
|
| 6 | + | ||
| 7 | + | ## Quickstart |
|
| 8 | + | ||
| 9 | + | ```bash |
|
| 10 | + | git clone https://github.com/stevedylandev/shrink.git |
|
| 11 | + | cd shrink |
|
| 12 | + | cargo build --release |
|
| 13 | + | ./target/release/shrink |
|
| 14 | + | ``` |
|
| 15 | + | ||
| 16 | + | ### Environment Variables |
|
| 17 | + | ||
| 18 | + | | Variable | Description | Default | |
|
| 19 | + | |---|---|---| |
|
| 20 | + | | `HOST` | Server bind host | `127.0.0.1` | |
|
| 21 | + | | `PORT` | Server bind port | `3000` | |
|
| 22 | + | ||
| 23 | + | ## Overview |
|
| 24 | + | ||
| 25 | + | A simple self-hosted tool for compressing and resizing images. Upload an image, set your desired quality and optional width, and download the compressed JPEG. A few highlights: |
|
| 26 | + | ||
| 27 | + | - Single Rust binary |
|
| 28 | + | - Compress images to JPEG with configurable quality (1-100) |
|
| 29 | + | - Optional resize by width (preserves aspect ratio) |
|
| 30 | + | - 20MB upload limit |
|
| 31 | + | ||
| 32 | + | ## Structure |
|
| 33 | + | ||
| 34 | + | ``` |
|
| 35 | + | shrink/ |
|
| 36 | + | ├── src/ |
|
| 37 | + | │ ├── main.rs # Entry point and server startup |
|
| 38 | + | │ └── server.rs # Axum routes and image compression logic |
|
| 39 | + | ├── templates/ |
|
| 40 | + | │ └── index.html # Upload UI |
|
| 41 | + | ├── static/ # Fonts and static assets |
|
| 42 | + | ├── Dockerfile |
|
| 43 | + | └── docker-compose.yml |
|
| 44 | + | ``` |
|
| 45 | + | ||
| 46 | + | ## Deployment |
|
| 47 | + | ||
| 48 | + | ### Docker (recommended) |
|
| 49 | + | ||
| 50 | + | ```bash |
|
| 51 | + | git clone https://github.com/stevedylandev/shrink.git |
|
| 52 | + | cd shrink |
|
| 53 | + | docker compose up -d |
|
| 54 | + | ``` |
|
| 55 | + | ||
| 56 | + | This will start Shrink on port `3000`. |
|
| 57 | + | ||
| 58 | + | ### Binary |
|
| 59 | + | ||
| 60 | + | ```bash |
|
| 61 | + | cargo build --release |
|
| 62 | + | ``` |
|
| 63 | + | ||
| 64 | + | The resulting binary at `./target/release/shrink` is self-contained. Copy it to your server along with the `static/` and `templates/` directories, then run it directly. |
|
| 65 | + | ||
| 66 | + | ## License |
|
| 67 | + | ||
| 68 | + | [MIT](LICENSE) |
| 1 | + | services: |
|
| 2 | + | app: |
|
| 3 | + | build: |
|
| 4 | + | context: ../.. |
|
| 5 | + | dockerfile: apps/shrink/Dockerfile |
|
| 6 | + | ports: |
|
| 7 | + | - "3000:3000" |
|
| 8 | + | environment: |
|
| 9 | + | - HOST=0.0.0.0 |
|
| 10 | + | - PORT=3000 |
|
| 11 | + | restart: unless-stopped |
| 1 | + | mod server; |
|
| 2 | + | ||
| 3 | + | #[tokio::main] |
|
| 4 | + | async fn main() { |
|
| 5 | + | tracing_subscriber::fmt::init(); |
|
| 6 | + | let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); |
|
| 7 | + | let port: u16 = std::env::var("PORT") |
|
| 8 | + | .ok() |
|
| 9 | + | .and_then(|v| v.parse().ok()) |
|
| 10 | + | .unwrap_or(3000); |
|
| 11 | + | server::run(host, port).await; |
|
| 12 | + | } |
| 1 | + | use askama::Template; |
|
| 2 | + | use axum::{ |
|
| 3 | + | Router, |
|
| 4 | + | extract::Multipart, |
|
| 5 | + | http::{StatusCode, header}, |
|
| 6 | + | response::{Html, IntoResponse, Response}, |
|
| 7 | + | routing::{get, post}, |
|
| 8 | + | }; |
|
| 9 | + | use axum::extract::DefaultBodyLimit; |
|
| 10 | + | use tower_http::services::ServeDir; |
|
| 11 | + | ||
| 12 | + | #[derive(Template)] |
|
| 13 | + | #[template(path = "index.html")] |
|
| 14 | + | struct IndexTemplate; |
|
| 15 | + | ||
| 16 | + | pub async fn run(host: String, port: u16) { |
|
| 17 | + | let app = Router::new() |
|
| 18 | + | .route("/", get(get_index)) |
|
| 19 | + | .route("/compress", post(post_compress)) |
|
| 20 | + | .layer(DefaultBodyLimit::max(20 * 1024 * 1024)) |
|
| 21 | + | .nest_service("/static", ServeDir::new("static")); |
|
| 22 | + | ||
| 23 | + | let addr = format!("{}:{}", host, port); |
|
| 24 | + | tracing::info!("Listening on {}", addr); |
|
| 25 | + | let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); |
|
| 26 | + | axum::serve(listener, app).await.unwrap(); |
|
| 27 | + | } |
|
| 28 | + | ||
| 29 | + | async fn get_index() -> impl IntoResponse { |
|
| 30 | + | let html = IndexTemplate.render().unwrap(); |
|
| 31 | + | Html(html) |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | async fn post_compress(mut multipart: Multipart) -> Result<Response, (StatusCode, String)> { |
|
| 35 | + | let mut file_data: Option<Vec<u8>> = None; |
|
| 36 | + | let mut quality: u8 = 80; |
|
| 37 | + | let mut width: u32 = 0; |
|
| 38 | + | let mut original_filename: String = "image".to_string(); |
|
| 39 | + | ||
| 40 | + | while let Ok(Some(field)) = multipart.next_field().await { |
|
| 41 | + | let name = field.name().unwrap_or("").to_string(); |
|
| 42 | + | match name.as_str() { |
|
| 43 | + | "file" => { |
|
| 44 | + | if let Some(fname) = field.file_name() { |
|
| 45 | + | original_filename = fname.to_string(); |
|
| 46 | + | } |
|
| 47 | + | let bytes = field |
|
| 48 | + | .bytes() |
|
| 49 | + | .await |
|
| 50 | + | .map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to read file: {}", e)))?; |
|
| 51 | + | file_data = Some(bytes.to_vec()); |
|
| 52 | + | } |
|
| 53 | + | "quality" => { |
|
| 54 | + | let text = field |
|
| 55 | + | .text() |
|
| 56 | + | .await |
|
| 57 | + | .map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to read quality: {}", e)))?; |
|
| 58 | + | quality = text.parse::<u8>().unwrap_or(80).clamp(1, 100); |
|
| 59 | + | } |
|
| 60 | + | "width" => { |
|
| 61 | + | let text = field |
|
| 62 | + | .text() |
|
| 63 | + | .await |
|
| 64 | + | .map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to read width: {}", e)))?; |
|
| 65 | + | width = text.parse::<u32>().unwrap_or(0); |
|
| 66 | + | } |
|
| 67 | + | _ => {} |
|
| 68 | + | } |
|
| 69 | + | } |
|
| 70 | + | ||
| 71 | + | let file_data = file_data.ok_or((StatusCode::BAD_REQUEST, "No file provided".to_string()))?; |
|
| 72 | + | ||
| 73 | + | let result = tokio::task::spawn_blocking(move || compress_image(&file_data, quality, width)) |
|
| 74 | + | .await |
|
| 75 | + | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Task failed: {}", e)))? |
|
| 76 | + | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Compression failed: {}", e)))?; |
|
| 77 | + | ||
| 78 | + | let download_name = build_download_filename(&original_filename, "jpg"); |
|
| 79 | + | ||
| 80 | + | Ok(( |
|
| 81 | + | StatusCode::OK, |
|
| 82 | + | [ |
|
| 83 | + | (header::CONTENT_TYPE, "image/jpeg".to_string()), |
|
| 84 | + | ( |
|
| 85 | + | header::CONTENT_DISPOSITION, |
|
| 86 | + | format!("attachment; filename=\"{}\"", download_name), |
|
| 87 | + | ), |
|
| 88 | + | ], |
|
| 89 | + | result, |
|
| 90 | + | ) |
|
| 91 | + | .into_response()) |
|
| 92 | + | } |
|
| 93 | + | ||
| 94 | + | fn compress_image(data: &[u8], quality: u8, width: u32) -> Result<Vec<u8>, String> { |
|
| 95 | + | let img = |
|
| 96 | + | image::load_from_memory(data).map_err(|e| format!("Failed to decode image: {}", e))?; |
|
| 97 | + | ||
| 98 | + | let img = if width > 0 && width != img.width() { |
|
| 99 | + | let aspect = img.height() as f64 / img.width() as f64; |
|
| 100 | + | let height = (width as f64 * aspect).round() as u32; |
|
| 101 | + | img.resize(width, height, image::imageops::FilterType::Lanczos3) |
|
| 102 | + | } else { |
|
| 103 | + | img |
|
| 104 | + | }; |
|
| 105 | + | ||
| 106 | + | let mut output = Vec::new(); |
|
| 107 | + | let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, quality); |
|
| 108 | + | img.write_with_encoder(encoder) |
|
| 109 | + | .map_err(|e| format!("JPEG encoding failed: {}", e))?; |
|
| 110 | + | ||
| 111 | + | Ok(output) |
|
| 112 | + | } |
|
| 113 | + | ||
| 114 | + | fn build_download_filename(original: &str, new_ext: &str) -> String { |
|
| 115 | + | let stem = std::path::Path::new(original) |
|
| 116 | + | .file_stem() |
|
| 117 | + | .and_then(|s| s.to_str()) |
|
| 118 | + | .unwrap_or("compressed"); |
|
| 119 | + | format!("{}_compressed.{}", stem, new_ext) |
|
| 120 | + | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | + | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | + | @font-face { |
|
| 2 | + | font-family: "Commit Mono"; |
|
| 3 | + | src: url("/static/fonts/CommitMono-400-Regular.otf") format("opentype"); |
|
| 4 | + | font-weight: 400; |
|
| 5 | + | font-style: normal; |
|
| 6 | + | } |
|
| 7 | + | ||
| 8 | + | @font-face { |
|
| 9 | + | font-family: "Commit Mono"; |
|
| 10 | + | src: url("/static/fonts/CommitMono-700-Regular.otf") format("opentype"); |
|
| 11 | + | font-weight: 700; |
|
| 12 | + | font-style: normal; |
|
| 13 | + | } |
|
| 14 | + | ||
| 15 | + | *, *::before, *::after { |
|
| 16 | + | margin: 0; |
|
| 17 | + | padding: 0; |
|
| 18 | + | box-sizing: border-box; |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | html { |
|
| 22 | + | background: #121113; |
|
| 23 | + | color: #ffffff; |
|
| 24 | + | font-family: "Commit Mono", monospace, sans-serif; |
|
| 25 | + | font-size: 14px; |
|
| 26 | + | line-height: 1.6; |
|
| 27 | + | -webkit-font-smoothing: antialiased; |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | body { |
|
| 31 | + | max-width: 700px; |
|
| 32 | + | margin: 0 auto; |
|
| 33 | + | padding: 0 1rem; |
|
| 34 | + | display: flex; |
|
| 35 | + | flex-direction: column; |
|
| 36 | + | gap: 1.5rem; |
|
| 37 | + | min-height: 100vh; |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | ::-webkit-scrollbar { |
|
| 41 | + | display: none; |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | header { |
|
| 45 | + | margin-top: 2rem; |
|
| 46 | + | padding-bottom: 1rem; |
|
| 47 | + | border-bottom: 1px solid #333; |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | .logo { |
|
| 51 | + | font-size: 28px; |
|
| 52 | + | font-weight: 700; |
|
| 53 | + | text-transform: uppercase; |
|
| 54 | + | color: #ffffff; |
|
| 55 | + | text-decoration: none; |
|
| 56 | + | letter-spacing: 0.05em; |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | main { |
|
| 60 | + | display: flex; |
|
| 61 | + | flex-direction: column; |
|
| 62 | + | gap: 1.5rem; |
|
| 63 | + | } |
|
| 64 | + | ||
| 65 | + | /* Drop Zone */ |
|
| 66 | + | #drop-zone { |
|
| 67 | + | border: 1px solid rgba(255, 255, 255, 0.3); |
|
| 68 | + | padding: 3rem 2rem; |
|
| 69 | + | text-align: center; |
|
| 70 | + | cursor: pointer; |
|
| 71 | + | display: flex; |
|
| 72 | + | align-items: center; |
|
| 73 | + | justify-content: center; |
|
| 74 | + | min-height: 150px; |
|
| 75 | + | transition: border-color 0.15s; |
|
| 76 | + | } |
|
| 77 | + | ||
| 78 | + | #drop-zone:hover, |
|
| 79 | + | #drop-zone.drag-over { |
|
| 80 | + | border-color: #ffffff; |
|
| 81 | + | } |
|
| 82 | + | ||
| 83 | + | #drop-zone p { |
|
| 84 | + | opacity: 0.5; |
|
| 85 | + | font-size: 14px; |
|
| 86 | + | text-transform: uppercase; |
|
| 87 | + | letter-spacing: 0.1em; |
|
| 88 | + | } |
|
| 89 | + | ||
| 90 | + | /* Preview */ |
|
| 91 | + | #preview-section { |
|
| 92 | + | display: none; |
|
| 93 | + | flex-direction: column; |
|
| 94 | + | gap: 0.5rem; |
|
| 95 | + | } |
|
| 96 | + | ||
| 97 | + | #preview-section.visible { |
|
| 98 | + | display: flex; |
|
| 99 | + | } |
|
| 100 | + | ||
| 101 | + | #preview-img { |
|
| 102 | + | max-width: 100%; |
|
| 103 | + | max-height: 300px; |
|
| 104 | + | object-fit: contain; |
|
| 105 | + | border: 1px solid #333; |
|
| 106 | + | } |
|
| 107 | + | ||
| 108 | + | #original-size { |
|
| 109 | + | opacity: 0.7; |
|
| 110 | + | font-size: 12px; |
|
| 111 | + | text-transform: uppercase; |
|
| 112 | + | } |
|
| 113 | + | ||
| 114 | + | /* Controls */ |
|
| 115 | + | #controls { |
|
| 116 | + | display: none; |
|
| 117 | + | flex-direction: column; |
|
| 118 | + | gap: 1rem; |
|
| 119 | + | } |
|
| 120 | + | ||
| 121 | + | #controls.visible { |
|
| 122 | + | display: flex; |
|
| 123 | + | } |
|
| 124 | + | ||
| 125 | + | .control-row { |
|
| 126 | + | display: flex; |
|
| 127 | + | align-items: center; |
|
| 128 | + | gap: 1rem; |
|
| 129 | + | } |
|
| 130 | + | ||
| 131 | + | .control-row label { |
|
| 132 | + | font-size: 12px; |
|
| 133 | + | opacity: 0.7; |
|
| 134 | + | text-transform: uppercase; |
|
| 135 | + | min-width: 80px; |
|
| 136 | + | } |
|
| 137 | + | ||
| 138 | + | #quality-value { |
|
| 139 | + | font-size: 14px; |
|
| 140 | + | min-width: 2.5em; |
|
| 141 | + | text-align: right; |
|
| 142 | + | } |
|
| 143 | + | ||
| 144 | + | #quality-note { |
|
| 145 | + | font-size: 12px; |
|
| 146 | + | opacity: 0.5; |
|
| 147 | + | text-transform: uppercase; |
|
| 148 | + | min-height: 1.2em; |
|
| 149 | + | } |
|
| 150 | + | ||
| 151 | + | /* Number Inputs */ |
|
| 152 | + | input[type="number"] { |
|
| 153 | + | background: #121113; |
|
| 154 | + | color: #ffffff; |
|
| 155 | + | border: 1px solid rgba(255, 255, 255, 0.5); |
|
| 156 | + | border-radius: 0; |
|
| 157 | + | padding: 0.4rem 0.75rem; |
|
| 158 | + | font-family: "Commit Mono", monospace, sans-serif; |
|
| 159 | + | font-size: 14px; |
|
| 160 | + | width: 90px; |
|
| 161 | + | outline: none; |
|
| 162 | + | -moz-appearance: textfield; |
|
| 163 | + | } |
|
| 164 | + | ||
| 165 | + | input[type="number"]::-webkit-inner-spin-button, |
|
| 166 | + | input[type="number"]::-webkit-outer-spin-button { |
|
| 167 | + | -webkit-appearance: none; |
|
| 168 | + | } |
|
| 169 | + | ||
| 170 | + | input[type="number"]:hover, |
|
| 171 | + | input[type="number"]:focus { |
|
| 172 | + | border-color: #ffffff; |
|
| 173 | + | } |
|
| 174 | + | ||
| 175 | + | .dimension-sep { |
|
| 176 | + | opacity: 0.5; |
|
| 177 | + | } |
|
| 178 | + | ||
| 179 | + | #height-display { |
|
| 180 | + | opacity: 0.7; |
|
| 181 | + | min-width: 4em; |
|
| 182 | + | } |
|
| 183 | + | ||
| 184 | + | /* Range Input */ |
|
| 185 | + | input[type="range"] { |
|
| 186 | + | -webkit-appearance: none; |
|
| 187 | + | appearance: none; |
|
| 188 | + | flex: 1; |
|
| 189 | + | height: 1px; |
|
| 190 | + | background: rgba(255, 255, 255, 0.3); |
|
| 191 | + | outline: none; |
|
| 192 | + | border-radius: 0; |
|
| 193 | + | } |
|
| 194 | + | ||
| 195 | + | input[type="range"]::-webkit-slider-thumb { |
|
| 196 | + | -webkit-appearance: none; |
|
| 197 | + | appearance: none; |
|
| 198 | + | width: 14px; |
|
| 199 | + | height: 14px; |
|
| 200 | + | background: #ffffff; |
|
| 201 | + | border: none; |
|
| 202 | + | border-radius: 0; |
|
| 203 | + | cursor: pointer; |
|
| 204 | + | } |
|
| 205 | + | ||
| 206 | + | input[type="range"]::-moz-range-thumb { |
|
| 207 | + | width: 14px; |
|
| 208 | + | height: 14px; |
|
| 209 | + | background: #ffffff; |
|
| 210 | + | border: none; |
|
| 211 | + | border-radius: 0; |
|
| 212 | + | cursor: pointer; |
|
| 213 | + | } |
|
| 214 | + | ||
| 215 | + | input[type="range"].disabled { |
|
| 216 | + | opacity: 0.3; |
|
| 217 | + | } |
|
| 218 | + | ||
| 219 | + | /* Select */ |
|
| 220 | + | select { |
|
| 221 | + | background: #121113; |
|
| 222 | + | color: #ffffff; |
|
| 223 | + | border: 1px solid rgba(255, 255, 255, 0.5); |
|
| 224 | + | border-radius: 0; |
|
| 225 | + | padding: 0.4rem 0.75rem; |
|
| 226 | + | font-family: "Commit Mono", monospace, sans-serif; |
|
| 227 | + | font-size: 14px; |
|
| 228 | + | cursor: pointer; |
|
| 229 | + | outline: none; |
|
| 230 | + | } |
|
| 231 | + | ||
| 232 | + | select:hover, |
|
| 233 | + | select:focus { |
|
| 234 | + | border-color: #ffffff; |
|
| 235 | + | } |
|
| 236 | + | ||
| 237 | + | /* Buttons */ |
|
| 238 | + | button { |
|
| 239 | + | background: #121113; |
|
| 240 | + | color: #ffffff; |
|
| 241 | + | border: 1px solid #ffffff; |
|
| 242 | + | border-radius: 0; |
|
| 243 | + | padding: 0.6rem 1.5rem; |
|
| 244 | + | font-family: "Commit Mono", monospace, sans-serif; |
|
| 245 | + | font-size: 14px; |
|
| 246 | + | text-transform: uppercase; |
|
| 247 | + | letter-spacing: 0.05em; |
|
| 248 | + | cursor: pointer; |
|
| 249 | + | transition: opacity 0.15s; |
|
| 250 | + | } |
|
| 251 | + | ||
| 252 | + | button:hover { |
|
| 253 | + | opacity: 0.7; |
|
| 254 | + | } |
|
| 255 | + | ||
| 256 | + | button:disabled { |
|
| 257 | + | opacity: 0.3; |
|
| 258 | + | cursor: default; |
|
| 259 | + | } |
|
| 260 | + | ||
| 261 | + | /* Results */ |
|
| 262 | + | #result-section { |
|
| 263 | + | display: none; |
|
| 264 | + | flex-direction: column; |
|
| 265 | + | gap: 1.5rem; |
|
| 266 | + | padding-top: 1.5rem; |
|
| 267 | + | border-top: 1px solid #333; |
|
| 268 | + | } |
|
| 269 | + | ||
| 270 | + | #result-section.visible { |
|
| 271 | + | display: flex; |
|
| 272 | + | } |
|
| 273 | + | ||
| 274 | + | .size-comparison { |
|
| 275 | + | display: flex; |
|
| 276 | + | gap: 2rem; |
|
| 277 | + | } |
|
| 278 | + | ||
| 279 | + | .size-comparison label { |
|
| 280 | + | font-size: 12px; |
|
| 281 | + | opacity: 0.5; |
|
| 282 | + | text-transform: uppercase; |
|
| 283 | + | display: block; |
|
| 284 | + | margin-bottom: 0.25rem; |
|
| 285 | + | } |
|
| 286 | + | ||
| 287 | + | .size-comparison p { |
|
| 288 | + | font-size: 16px; |
|
| 289 | + | } |
|
| 290 | + | ||
| 291 | + | #download-link { |
|
| 292 | + | text-decoration: none; |
|
| 293 | + | align-self: flex-start; |
|
| 294 | + | } |
| 1 | + | <!DOCTYPE html> |
|
| 2 | + | <html lang="en"> |
|
| 3 | + | <head> |
|
| 4 | + | <meta charset="UTF-8"> |
|
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
| 6 | + | <meta name="theme-color" content="#121113"> |
|
| 7 | + | <title>{% block title %}SHRINK{% endblock %}</title> |
|
| 8 | + | <meta name="description" content="Shorten URLs with SHRINK"> |
|
| 9 | + | <meta property="og:title" content="SHRINK"> |
|
| 10 | + | <meta property="og:description" content="Shorten URLs with SHRINK"> |
|
| 11 | + | <meta property="og:image" content="/static/og.png"> |
|
| 12 | + | <meta property="og:type" content="website"> |
|
| 13 | + | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 14 | + | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 15 | + | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 16 | + | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 17 | + | <link rel="stylesheet" href="/static/styles.css"> |
|
| 18 | + | </head> |
|
| 19 | + | <body> |
|
| 20 | + | <header> |
|
| 21 | + | <a href="/" class="logo">SHRINK</a> |
|
| 22 | + | </header> |
|
| 23 | + | <main> |
|
| 24 | + | {% block content %}{% endblock %} |
|
| 25 | + | </main> |
|
| 26 | + | </body> |
|
| 27 | + | </html> |
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}SHRINK{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | ||
| 5 | + | <div id="drop-zone"> |
|
| 6 | + | <p>DROP IMAGE HERE OR CLICK TO SELECT</p> |
|
| 7 | + | <input type="file" id="file-input" accept="image/*" hidden> |
|
| 8 | + | </div> |
|
| 9 | + | ||
| 10 | + | <div id="preview-section"> |
|
| 11 | + | <img id="preview-img" alt="Preview"> |
|
| 12 | + | <p id="original-size"></p> |
|
| 13 | + | </div> |
|
| 14 | + | ||
| 15 | + | <div id="controls"> |
|
| 16 | + | <div class="control-row"> |
|
| 17 | + | <label for="quality-slider">QUALITY</label> |
|
| 18 | + | <input type="range" id="quality-slider" min="1" max="100" value="80"> |
|
| 19 | + | <span id="quality-value">80</span> |
|
| 20 | + | </div> |
|
| 21 | + | <div class="control-row"> |
|
| 22 | + | <label>RESIZE</label> |
|
| 23 | + | <input type="number" id="width-input" placeholder="WIDTH" min="1"> |
|
| 24 | + | <span class="dimension-sep">x</span> |
|
| 25 | + | <span id="height-display">—</span> |
|
| 26 | + | </div> |
|
| 27 | + | <button id="compress-btn">COMPRESS</button> |
|
| 28 | + | </div> |
|
| 29 | + | ||
| 30 | + | <div id="result-section"> |
|
| 31 | + | <div class="size-comparison"> |
|
| 32 | + | <div> |
|
| 33 | + | <label>ORIGINAL</label> |
|
| 34 | + | <p id="result-original-size"></p> |
|
| 35 | + | </div> |
|
| 36 | + | <div> |
|
| 37 | + | <label>COMPRESSED</label> |
|
| 38 | + | <p id="result-compressed-size"></p> |
|
| 39 | + | </div> |
|
| 40 | + | <div> |
|
| 41 | + | <label>REDUCTION</label> |
|
| 42 | + | <p id="result-reduction"></p> |
|
| 43 | + | </div> |
|
| 44 | + | </div> |
|
| 45 | + | <a id="download-link"> |
|
| 46 | + | <button>DOWNLOAD</button> |
|
| 47 | + | </a> |
|
| 48 | + | </div> |
|
| 49 | + | ||
| 50 | + | <script> |
|
| 51 | + | const dropZone = document.getElementById('drop-zone'); |
|
| 52 | + | const fileInput = document.getElementById('file-input'); |
|
| 53 | + | const previewSection = document.getElementById('preview-section'); |
|
| 54 | + | const previewImg = document.getElementById('preview-img'); |
|
| 55 | + | const originalSize = document.getElementById('original-size'); |
|
| 56 | + | const controls = document.getElementById('controls'); |
|
| 57 | + | const qualitySlider = document.getElementById('quality-slider'); |
|
| 58 | + | const qualityValue = document.getElementById('quality-value'); |
|
| 59 | + | const widthInput = document.getElementById('width-input'); |
|
| 60 | + | const heightDisplay = document.getElementById('height-display'); |
|
| 61 | + | const compressBtn = document.getElementById('compress-btn'); |
|
| 62 | + | const resultSection = document.getElementById('result-section'); |
|
| 63 | + | const resultOriginalSize = document.getElementById('result-original-size'); |
|
| 64 | + | const resultCompressedSize = document.getElementById('result-compressed-size'); |
|
| 65 | + | const resultReduction = document.getElementById('result-reduction'); |
|
| 66 | + | const downloadLink = document.getElementById('download-link'); |
|
| 67 | + | ||
| 68 | + | let selectedFile = null; |
|
| 69 | + | let naturalWidth = 0; |
|
| 70 | + | let naturalHeight = 0; |
|
| 71 | + | ||
| 72 | + | function formatBytes(bytes) { |
|
| 73 | + | if (bytes < 1024) return bytes + ' B'; |
|
| 74 | + | if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; |
|
| 75 | + | return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; |
|
| 76 | + | } |
|
| 77 | + | ||
| 78 | + | function handleFile(file) { |
|
| 79 | + | if (!file || !file.type.startsWith('image/')) return; |
|
| 80 | + | selectedFile = file; |
|
| 81 | + | const url = URL.createObjectURL(file); |
|
| 82 | + | previewImg.src = url; |
|
| 83 | + | originalSize.textContent = formatBytes(file.size); |
|
| 84 | + | previewSection.classList.add('visible'); |
|
| 85 | + | controls.classList.add('visible'); |
|
| 86 | + | resultSection.classList.remove('visible'); |
|
| 87 | + | ||
| 88 | + | const tmp = new Image(); |
|
| 89 | + | tmp.onload = () => { |
|
| 90 | + | naturalWidth = tmp.naturalWidth; |
|
| 91 | + | naturalHeight = tmp.naturalHeight; |
|
| 92 | + | widthInput.value = naturalWidth; |
|
| 93 | + | heightDisplay.textContent = naturalHeight; |
|
| 94 | + | }; |
|
| 95 | + | tmp.src = url; |
|
| 96 | + | } |
|
| 97 | + | ||
| 98 | + | dropZone.addEventListener('click', () => fileInput.click()); |
|
| 99 | + | fileInput.addEventListener('change', () => { |
|
| 100 | + | if (fileInput.files.length) handleFile(fileInput.files[0]); |
|
| 101 | + | }); |
|
| 102 | + | ||
| 103 | + | dropZone.addEventListener('dragover', (e) => { |
|
| 104 | + | e.preventDefault(); |
|
| 105 | + | dropZone.classList.add('drag-over'); |
|
| 106 | + | }); |
|
| 107 | + | dropZone.addEventListener('dragleave', () => { |
|
| 108 | + | dropZone.classList.remove('drag-over'); |
|
| 109 | + | }); |
|
| 110 | + | dropZone.addEventListener('drop', (e) => { |
|
| 111 | + | e.preventDefault(); |
|
| 112 | + | dropZone.classList.remove('drag-over'); |
|
| 113 | + | if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]); |
|
| 114 | + | }); |
|
| 115 | + | ||
| 116 | + | qualitySlider.addEventListener('input', () => { |
|
| 117 | + | qualityValue.textContent = qualitySlider.value; |
|
| 118 | + | }); |
|
| 119 | + | ||
| 120 | + | widthInput.addEventListener('input', () => { |
|
| 121 | + | if (naturalWidth > 0) { |
|
| 122 | + | const w = parseInt(widthInput.value) || 0; |
|
| 123 | + | heightDisplay.textContent = w > 0 ? Math.round(w * naturalHeight / naturalWidth) : '—'; |
|
| 124 | + | } |
|
| 125 | + | }); |
|
| 126 | + | ||
| 127 | + | compressBtn.addEventListener('click', async () => { |
|
| 128 | + | if (!selectedFile) return; |
|
| 129 | + | compressBtn.disabled = true; |
|
| 130 | + | compressBtn.textContent = 'COMPRESSING...'; |
|
| 131 | + | ||
| 132 | + | const formData = new FormData(); |
|
| 133 | + | formData.append('file', selectedFile); |
|
| 134 | + | formData.append('quality', qualitySlider.value); |
|
| 135 | + | const w = parseInt(widthInput.value) || 0; |
|
| 136 | + | if (w > 0) { |
|
| 137 | + | formData.append('width', w.toString()); |
|
| 138 | + | } |
|
| 139 | + | ||
| 140 | + | try { |
|
| 141 | + | const res = await fetch('/compress', { method: 'POST', body: formData }); |
|
| 142 | + | if (!res.ok) { |
|
| 143 | + | const text = await res.text(); |
|
| 144 | + | alert('Compression failed: ' + text); |
|
| 145 | + | return; |
|
| 146 | + | } |
|
| 147 | + | const blob = await res.blob(); |
|
| 148 | + | const originalBytes = selectedFile.size; |
|
| 149 | + | const compressedBytes = blob.size; |
|
| 150 | + | const reduction = ((1 - compressedBytes / originalBytes) * 100).toFixed(1); |
|
| 151 | + | ||
| 152 | + | resultOriginalSize.textContent = formatBytes(originalBytes); |
|
| 153 | + | resultCompressedSize.textContent = formatBytes(compressedBytes); |
|
| 154 | + | resultReduction.textContent = reduction + '%'; |
|
| 155 | + | ||
| 156 | + | if (downloadLink.href && downloadLink.href.startsWith('blob:')) { |
|
| 157 | + | URL.revokeObjectURL(downloadLink.href); |
|
| 158 | + | } |
|
| 159 | + | downloadLink.href = URL.createObjectURL(blob); |
|
| 160 | + | downloadLink.download = selectedFile.name.replace(/\.[^.]+$/, '') + '_compressed.jpg'; |
|
| 161 | + | resultSection.classList.add('visible'); |
|
| 162 | + | } catch (err) { |
|
| 163 | + | alert('Error: ' + err.message); |
|
| 164 | + | } finally { |
|
| 165 | + | compressBtn.disabled = false; |
|
| 166 | + | compressBtn.textContent = 'COMPRESS'; |
|
| 167 | + | } |
|
| 168 | + | }); |
|
| 169 | + | </script> |
|
| 170 | + | ||
| 171 | + | {% endblock %} |
| 1 | + | SIPP_API_KEY=your-secret-key |
|
| 2 | + | SIPP_AUTH_ENDPOINTS=api_delete,api_list |
|
| 3 | + | SIPP_DB_PATH=sipp.sqlite |
|
| 4 | + | SIPP_REMOTE_URL=http://your-server.com |
| 1 | + | # Changelog |
|
| 2 | + | ||
| 3 | + | All notable changes to this project will be documented in this file. |
|
| 4 | + | ||
| 5 | + | ## [Unreleased] |
|
| 6 | + | ||
| 7 | + | ### Miscellaneous |
|
| 8 | + | ||
| 9 | + | - Add changelog workflow |
|
| 10 | + | - Update changelog |
|
| 11 | + | - Added wrapping when creating inside tui |
|
| 12 | + | - Update changelog |
|
| 13 | + | - Replaced rand with nanoid |
|
| 14 | + | ||
| 15 | + | ## [0.1.4] - 2026-02-21 |
|
| 16 | + | ||
| 17 | + | ### Miscellaneous |
|
| 18 | + | ||
| 19 | + | - Added db path env |
|
| 20 | + | - Update README |
|
| 21 | + | - Update readme |
|
| 22 | + | - Update readme |
|
| 23 | + | - Update readme |
|
| 24 | + | - Added max file size and plain text response for curl |
|
| 25 | + | - Updated workflows |
|
| 26 | + | - Version bump |
|
| 27 | + | ||
| 28 | + | ## [0.1.3] - 2026-02-21 |
|
| 29 | + | ||
| 30 | + | ### Miscellaneous |
|
| 31 | + | ||
| 32 | + | - Update readme |
|
| 33 | + | - Update readme |
|
| 34 | + | - Added update and search |
|
| 35 | + | - Version bump |
|
| 36 | + | ||
| 37 | + | ## [0.1.2] - 2026-02-20 |
|
| 38 | + | ||
| 39 | + | ### Bug Fixes |
|
| 40 | + | ||
| 41 | + | - Patch over ts tsx jsx issue |
|
| 42 | + | ||
| 43 | + | ### Miscellaneous |
|
| 44 | + | ||
| 45 | + | - Updated TUI interface |
|
| 46 | + | - Added proper error handling |
|
| 47 | + | - Added git cliff workflow |
|
| 48 | + | - Version bump |
|
| 49 | + | ||
| 50 | + | ## [0.1.1] - 2026-02-20 |
|
| 51 | + | ||
| 52 | + | ### Miscellaneous |
|
| 53 | + | ||
| 54 | + | - Updated workflow |
|
| 55 | + | - Updated workflow |
|
| 56 | + | - Updated docker info |
|
| 57 | + | - Removed about page |
|
| 58 | + | - Update readme |
|
| 59 | + | - Updated html |
|
| 60 | + | - Fixed svg |
|
| 61 | + | - Version bump |
|
| 62 | + | ||
| 63 | + | ## [0.1.0] - 2026-02-19 |
|
| 64 | + | ||
| 65 | + | ### Features |
|
| 66 | + | ||
| 67 | + | - Init |
|
| 68 | + | - Added tui |
|
| 69 | + | - Added syntax highlighting to snippets |
|
| 70 | + | - Added remote access via tui and api key |
|
| 71 | + | - Added config auth setup |
|
| 72 | + | ||
| 73 | + | ### Miscellaneous |
|
| 74 | + | ||
| 75 | + | - Added tui scroll for snipps |
|
| 76 | + | - Added syntax highlighting |
|
| 77 | + | - TUI help menu |
|
| 78 | + | - Added delete to snippet |
|
| 79 | + | - Tui enhancements |
|
| 80 | + | - Added link copy to tui |
|
| 81 | + | - Tui improvements |
|
| 82 | + | - Merged tui and server into single binary |
|
| 83 | + | - Server auth config |
|
| 84 | + | - Added slow equal for auth ccheck |
|
| 85 | + | - Bumped packages and renamed to sipp |
|
| 86 | + | - Renamed to sipp-so |
|
| 87 | + | - Covered edge cases with no config setup |
|
| 88 | + | - Update gitignore |
|
| 89 | + | - Update README |
|
| 90 | + | - Add LICENSE |
|
| 91 | + | - Update cargo.toml |
|
| 92 | + | - Add Rust CI workflow |
|
| 93 | + | - Added release workflow |
|
| 94 | + |
| 1 | + | [package] |
|
| 2 | + | name = "sipp-so" |
|
| 3 | + | version = "0.1.5" |
|
| 4 | + | edition = "2024" |
|
| 5 | + | description = "Minimal code sharing - single binary for web server, CLI, and TUI" |
|
| 6 | + | license = "MIT" |
|
| 7 | + | repository = "https://github.com/stevedylandev/sipp" |
|
| 8 | + | homepage = "https://sipp.so" |
|
| 9 | + | documentation = "https://github.com/stevedylandev/sipp#readme" |
|
| 10 | + | readme = "README.md" |
|
| 11 | + | keywords = ["cli", "tui", "snippet", "code-sharing", "pastebin"] |
|
| 12 | + | categories = ["command-line-utilities", "web-programming"] |
|
| 13 | + | authors = ["Steve Simkins"] |
|
| 14 | + | exclude = [".github", "*.png", "*.gif"] |
|
| 15 | + | ||
| 16 | + | [[bin]] |
|
| 17 | + | name = "sipp" |
|
| 18 | + | path = "src/main.rs" |
|
| 19 | + | ||
| 20 | + | [dependencies] |
|
| 21 | + | axum = { workspace = true } |
|
| 22 | + | tokio = { workspace = true } |
|
| 23 | + | serde = { workspace = true } |
|
| 24 | + | serde_json = { workspace = true } |
|
| 25 | + | tower-http = { workspace = true, features = ["fs"] } |
|
| 26 | + | nanoid = { workspace = true } |
|
| 27 | + | rust-embed = { workspace = true } |
|
| 28 | + | dotenvy = { workspace = true } |
|
| 29 | + | subtle = { workspace = true } |
|
| 30 | + | rusqlite = { workspace = true } |
|
| 31 | + | askama = "0.15.4" |
|
| 32 | + | askama_web = { version = "0.15.1", features = ["axum-0.8"] } |
|
| 33 | + | ratatui = "0.30" |
|
| 34 | + | crossterm = "0.29" |
|
| 35 | + | arboard = "3" |
|
| 36 | + | syntect = "5" |
|
| 37 | + | reqwest = { version = "0.13", features = ["json", "blocking"] } |
|
| 38 | + | clap = { version = "4", features = ["derive", "env"] } |
|
| 39 | + | toml = "1.0" |
|
| 40 | + | rpassword = "7" |
|
| 41 | + | open = "5.3.3" |
| 1 | + | # Build from repo root: docker build -t sipp -f apps/sipp/Dockerfile . |
|
| 2 | + | FROM rust:1-slim-bookworm AS builder |
|
| 3 | + | WORKDIR /app |
|
| 4 | + | ||
| 5 | + | # Copy workspace manifests |
|
| 6 | + | COPY Cargo.toml Cargo.lock ./ |
|
| 7 | + | COPY crates/auth/Cargo.toml crates/auth/ |
|
| 8 | + | COPY apps/sipp/Cargo.toml apps/sipp/ |
|
| 9 | + | COPY apps/feeds/Cargo.toml apps/feeds/ |
|
| 10 | + | COPY apps/parcels/Cargo.toml apps/parcels/ |
|
| 11 | + | COPY apps/jotts/Cargo.toml apps/jotts/ |
|
| 12 | + | COPY apps/og/Cargo.toml apps/og/ |
|
| 13 | + | COPY apps/shrink/Cargo.toml apps/shrink/ |
|
| 14 | + | ||
| 15 | + | # Create stubs for dependency caching |
|
| 16 | + | RUN mkdir -p crates/auth/src && echo '' > crates/auth/src/lib.rs \ |
|
| 17 | + | && for app in sipp feeds parcels jotts og shrink; do \ |
|
| 18 | + | mkdir -p apps/$app/src && echo 'fn main() {}' > apps/$app/src/main.rs; \ |
|
| 19 | + | done |
|
| 20 | + | ||
| 21 | + | RUN cargo build --release -p sipp-so |
|
| 22 | + | ||
| 23 | + | # Copy real source |
|
| 24 | + | COPY crates/auth/src crates/auth/src |
|
| 25 | + | COPY apps/sipp/src apps/sipp/src |
|
| 26 | + | COPY apps/sipp/assets apps/sipp/assets |
|
| 27 | + | COPY apps/sipp/static apps/sipp/static |
|
| 28 | + | COPY apps/sipp/templates apps/sipp/templates |
|
| 29 | + | ||
| 30 | + | RUN touch apps/sipp/src/*.rs crates/auth/src/*.rs && cargo build --release -p sipp-so |
|
| 31 | + | ||
| 32 | + | FROM debian:bookworm-slim |
|
| 33 | + | COPY --from=builder /app/target/release/sipp /usr/local/bin/sipp |
|
| 34 | + | WORKDIR /data |
|
| 35 | + | EXPOSE 3000 |
|
| 36 | + | CMD ["sipp", "server", "--port", "3000", "--host", "0.0.0.0"] |
| 1 | + | MIT License |
|
| 2 | + | ||
| 3 | + | Copyright (c) 2026 Steve Simkins |
|
| 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. |
| 1 | + | # Sipp |
|
| 2 | + | ||
| 3 | + | Minimal code sharing |
|
| 4 | + | ||
| 5 | + | https://github.com/user-attachments/assets/cadafb70-f796-456d-bfd9-e88704e7132c |
|
| 6 | + | ||
| 7 | + | ## Features |
|
| 8 | + | ||
| 9 | + | - Single binary for web server and TUI |
|
| 10 | + | - Create snippets and share on the web |
|
| 11 | + | - Raw output for CLI tools — `curl`, `wget`, and `httpie` get plain text automatically |
|
| 12 | + | - Interactive TUI with authenticated access for snippet management |
|
| 13 | + | - Minimal, fast, and low memory consumption |
|
| 14 | + | ||
| 15 | + | ## Quickstart |
|
| 16 | + | ||
| 17 | + | **1. Install** |
|
| 18 | + | ||
| 19 | + | Install via the [releases](https://github.com/stevedylandev/sipp/releases) page, or directly with `cargo` |
|
| 20 | + | ||
| 21 | + | ```bash |
|
| 22 | + | cargo install sipp-so |
|
| 23 | + | ``` |
|
| 24 | + | ||
| 25 | + | To confirm it was installed correctly run the following |
|
| 26 | + | ||
| 27 | + | ```bash |
|
| 28 | + | sipp --help |
|
| 29 | + | ``` |
|
| 30 | + | ||
| 31 | + | **2. Start Server** |
|
| 32 | + | ||
| 33 | + | For demo purposes you can run this locally, but ideally this would be run in a deployment server with a proper ENV setup with your admin key. |
|
| 34 | + | ||
| 35 | + | ```bash |
|
| 36 | + | sipp server --port 3000 |
|
| 37 | + | ``` |
|
| 38 | + | ||
| 39 | + | **3. Create a Snippet** |
|
| 40 | + | ||
| 41 | + | You can either open up `http://localhost:3000` and create a snippet in a web browser, or use the TUI. In the same directory, open a new terminal window and use |
|
| 42 | + | ||
| 43 | + | ```bash |
|
| 44 | + | # Path to file |
|
| 45 | + | sipp path/to/file.rs |
|
| 46 | + | ||
| 47 | + | # Or use the interactive tui |
|
| 48 | + | sipp |
|
| 49 | + | ``` |
|
| 50 | + | ||
| 51 | + | ## Demo Instance |
|
| 52 | + | ||
| 53 | + | A small instance running at [sipp.so](https://sipp.so) that can be used for testing and demo purposes. |
|
| 54 | + | ||
| 55 | + | ```bash |
|
| 56 | + | sipp -r https://sipp.so |
|
| 57 | + | ``` |
|
| 58 | + | ||
| 59 | + | >[!WARNING] |
|
| 60 | + | >All snippets created here are public and might be deleted at any time; host your own instance with your own API key for personal use! |
|
| 61 | + | ||
| 62 | + | ## Install |
|
| 63 | + | ||
| 64 | + | Sipp can be installed several ways |
|
| 65 | + | ||
| 66 | + | ### Releases |
|
| 67 | + | ||
| 68 | + | Visit the [releases](https://github.com/stevedylandev/sipp/releases) page and install through cURL script and other methods. |
|
| 69 | + | ||
| 70 | + | ### Homebrew |
|
| 71 | + | ||
| 72 | + | ``` |
|
| 73 | + | brew install stevedylandev/tap/sipp-so |
|
| 74 | + | ``` |
|
| 75 | + | ||
| 76 | + | ### Cargo |
|
| 77 | + | ||
| 78 | + | ```bash |
|
| 79 | + | cargo install sipp-so |
|
| 80 | + | ``` |
|
| 81 | + | ||
| 82 | + | ## Usage |
|
| 83 | + | ||
| 84 | + | ### CLI |
|
| 85 | + | ||
| 86 | + | ``` |
|
| 87 | + | sipp [OPTIONS] [FILE] [COMMAND] |
|
| 88 | + | ``` |
|
| 89 | + | ||
| 90 | + | #### Commands |
|
| 91 | + | ||
| 92 | + | | Command | Description | |
|
| 93 | + | |---|---| |
|
| 94 | + | | `server` | Start the web server | |
|
| 95 | + | | `tui` | Launch the interactive TUI | |
|
| 96 | + | | `auth` | Save remote URL and API key to config file | |
|
| 97 | + | ||
| 98 | + | #### Arguments |
|
| 99 | + | ||
| 100 | + | | Argument | Description | |
|
| 101 | + | |---|---| |
|
| 102 | + | | `[FILE]` | File path to create a snippet from | |
|
| 103 | + | ||
| 104 | + | #### Options |
|
| 105 | + | ||
| 106 | + | | Option | Description | |
|
| 107 | + | |---|---| |
|
| 108 | + | | `-r, --remote <URL>` | Remote server URL (e.g. `http://localhost:3000`) (env: `SIPP_REMOTE_URL`) | |
|
| 109 | + | | `-k, --api-key <KEY>` | API key for authenticated operations (env: `SIPP_API_KEY`) | |
|
| 110 | + | ||
| 111 | + | ### Server |
|
| 112 | + | ||
| 113 | + | Sipp includes a built-in web server powered by Axum. Start it with: |
|
| 114 | + | ||
| 115 | + | ```bash |
|
| 116 | + | sipp server --port 3000 --host localhost |
|
| 117 | + | ``` |
|
| 118 | + | ||
| 119 | + | #### Environment Variables |
|
| 120 | + | ||
| 121 | + | | Variable | Description | |
|
| 122 | + | |---|---| |
|
| 123 | + | | `SIPP_API_KEY` | API key for protecting endpoints | |
|
| 124 | + | | `SIPP_AUTH_ENDPOINTS` | Comma-separated list of endpoints requiring auth: `api_list`, `api_create`, `api_get`, `api_delete`, `all`, or `none` (defaults to `api_delete,api_list`) | |
|
| 125 | + | | `SIPP_MAX_CONTENT_SIZE` | Maximum snippet content size in bytes (defaults to `512000` / 500 KB) | |
|
| 126 | + | | `SIPP_DB_PATH` | Custom path for the SQLite database file (defaults to `sipp.sqlite` in the working directory) | |
|
| 127 | + | ||
| 128 | + | The server stores snippets in a local `sipp.sqlite` SQLite database. |
|
| 129 | + | ||
| 130 | + | #### API Endpoints |
|
| 131 | + | ||
| 132 | + | | Method | Endpoint | Description | |
|
| 133 | + | |---|---|---| |
|
| 134 | + | | `GET` | `/api/snippets` | List all snippets | |
|
| 135 | + | | `POST` | `/api/snippets` | Create a snippet (`{"name": "...", "content": "..."}`) | |
|
| 136 | + | | `GET` | `/api/snippets/{short_id}` | Get a snippet by ID | |
|
| 137 | + | | `PUT` | `/api/snippets/{short_id}` | Update a snippet (`{"name": "...", "content": "..."}`) | |
|
| 138 | + | | `DELETE` | `/api/snippets/{short_id}` | Delete a snippet by ID | |
|
| 139 | + | ||
| 140 | + | Authenticated endpoints require an `x-api-key` header. |
|
| 141 | + | ||
| 142 | + | #### Raw Output for CLI Tools |
|
| 143 | + | ||
| 144 | + | When you access a snippet URL (`/s/{short_id}`) with `curl`, `wget`, or `httpie`, the server returns the raw content as plain text instead of HTML: |
|
| 145 | + | ||
| 146 | + | ```bash |
|
| 147 | + | curl https://sipp.so/s/abc123 |
|
| 148 | + | ``` |
|
| 149 | + | ||
| 150 | + | ### TUI |
|
| 151 | + | ||
| 152 | + | The Sipp TUI makes it easy to create, copy, share, and manage your snippets either locally or remotely. Launch it with: |
|
| 153 | + | ||
| 154 | + | ```bash |
|
| 155 | + | # Launch TUI (default behavior when no file argument is given) |
|
| 156 | + | sipp |
|
| 157 | + | ||
| 158 | + | # Or explicitly |
|
| 159 | + | sipp tui |
|
| 160 | + | ||
| 161 | + | # With remote options |
|
| 162 | + | sipp -r https://sipp.so -k your-api-key |
|
| 163 | + | ``` |
|
| 164 | + | ||
| 165 | + | #### Local Access |
|
| 166 | + | ||
| 167 | + | If you are running `sipp` in the same directory as the `sipp.sqlite` file created by the server instance, the TUI will automatically access the datebase locally and you can edit it directly. |
|
| 168 | + | ||
| 169 | + | #### Remote Access |
|
| 170 | + | ||
| 171 | + | To access a remote instance of Sipp make sure to do the following: |
|
| 172 | + | - Set the `SIPP_API_KEY` variable in your server instance |
|
| 173 | + | - Run `sipp auth` to enter in your server instance URL and the API key, which will be stored under `$HOME/.config/sipp`. You can also set these with the ENV variables `SIPP_REMOTE_URL` and `SIPP_API_KEY` |
|
| 174 | + | ||
| 175 | + | >[!NOTE] |
|
| 176 | + | >You can try a limited remote instance without an API key with `sipp -r https://sipp.so` |
|
| 177 | + | ||
| 178 | + | #### Actions |
|
| 179 | + | ||
| 180 | + | While inside the TUI the following actions are available |
|
| 181 | + | ||
| 182 | + | | Key | Action | |
|
| 183 | + | |---|---| |
|
| 184 | + | | `j`/`↓` | Move down / Scroll down | |
|
| 185 | + | | `k`/`↑` | Move up / Scroll up | |
|
| 186 | + | | `Enter` | Focus content pane | |
|
| 187 | + | | `Esc` | Back / Quit | |
|
| 188 | + | | `y` | Copy snippet content | |
|
| 189 | + | | `Y` | Copy snippet link | |
|
| 190 | + | | `o` | Open in browser | |
|
| 191 | + | | `e` | Edit snippet | |
|
| 192 | + | | `d` | Delete snippet | |
|
| 193 | + | | `c` | Create snippet | |
|
| 194 | + | | `/` | Search snippets | |
|
| 195 | + | | `r` | Refresh snippets (remote only) | |
|
| 196 | + | | `q` | Quit | |
|
| 197 | + | | `?` | Toggle help | |
|
| 198 | + | ||
| 199 | + | ## Deployment |
|
| 200 | + | ||
| 201 | + | Since Sipp is a single binary it can be run in virtually any enviornment. |
|
| 202 | + | ||
| 203 | + | ### Systemd |
|
| 204 | + | ||
| 205 | + | Create a service file at `/etc/systemd/system/sipp.service`: |
|
| 206 | + | ||
| 207 | + | ```ini |
|
| 208 | + | [Unit] |
|
| 209 | + | Description=Sipp snippet server |
|
| 210 | + | After=network.target |
|
| 211 | + | ||
| 212 | + | [Service] |
|
| 213 | + | ExecStart=/usr/local/bin/sipp server --port 3000 --host 0.0.0.0 |
|
| 214 | + | Environment=SIPP_API_KEY=your-secret-key |
|
| 215 | + | WorkingDirectory=/var/lib/sipp |
|
| 216 | + | Restart=on-failure |
|
| 217 | + | ||
| 218 | + | [Install] |
|
| 219 | + | WantedBy=multi-user.target |
|
| 220 | + | ``` |
|
| 221 | + | ||
| 222 | + | ```bash |
|
| 223 | + | sudo systemctl enable --now sipp |
|
| 224 | + | ``` |
|
| 225 | + | ||
| 226 | + | ### Docker |
|
| 227 | + | ||
| 228 | + | A `Dockerfile` and `docker-compose.yml` are included in the repository. |
|
| 229 | + | ||
| 230 | + | ```bash |
|
| 231 | + | # Using Docker Compose (recommended) |
|
| 232 | + | SIPP_API_KEY=your-secret-key docker compose up -d |
|
| 233 | + | ||
| 234 | + | # Or build and run manually |
|
| 235 | + | docker build -t sipp . |
|
| 236 | + | docker run -p 3000:3000 -e SIPP_API_KEY=your-secret-key -v sipp-data:/data sipp |
|
| 237 | + | ``` |
|
| 238 | + | ||
| 239 | + | ### Railway |
|
| 240 | + | ||
| 241 | + | 1. Fork this repo and connect your fork to [Railway](https://railway.app) |
|
| 242 | + | 2. Set the environment variables `SIPP_API_KEY` and optionally `SIPP_AUTH_ENDPOINTS` |
|
| 243 | + | 3. Add a [volume](https://docs.railway.com/guides/volumes) to your service and mount it at `/data` |
|
| 244 | + | 4. Set `SIPP_DB_PATH` to `/data/sipp.sqlite` so the database persists across deploys |
| 1 | + | - [x] Allow creating snippet via file path |
|
| 2 | + | - [x] Use dotfile config to store creds, make an auth command |
|
| 3 | + | - [x] Combine tui and server into one binary? ie `sipp server --port 3000` |
|
| 4 | + | - [x] Server config to enable or disable certain endpoints as authenticated |
|
| 5 | + | - [x] Server config as env / .env? |
|
| 6 | + | - [x] README |
|
| 7 | + | - [x] Add help menu to bottom status bar |
|
| 8 | + | - [x] Make messages pop ups instead of status bar |
|
| 9 | + | - [x] Figure out ts and tsx issues for syntax highlighting |
|
| 10 | + | - [x] Look for SQL injection possibilities |
|
| 11 | + | - [x] Make sure DB can handle multiple connections |
|
| 12 | + | - [x] Find way to handle changelog and release notes in automation |
|
| 13 | + | - [x] Confirm delete in TUI pop up |
|
| 14 | + | - [x] Edit in TUI? |
|
| 15 | + | - [x] Curl URLs for just content |
|
| 16 | + | - [x] Limit on file size |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | + | {"name":"","short_name":"","icons":[{"src":"/assets/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/assets/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | + | [changelog] |
|
| 2 | + | header = """ |
|
| 3 | + | # Changelog\n |
|
| 4 | + | All notable changes to this project will be documented in this file.\n |
|
| 5 | + | """ |
|
| 6 | + | body = """ |
|
| 7 | + | {%- macro remote_url() -%} |
|
| 8 | + | https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} |
|
| 9 | + | {%- endmacro -%} |
|
| 10 | + | ||
| 11 | + | {% if version -%} |
|
| 12 | + | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} |
|
| 13 | + | {% else -%} |
|
| 14 | + | ## [Unreleased] |
|
| 15 | + | {% endif -%} |
|
| 16 | + | ||
| 17 | + | {% for group, commits in commits | group_by(attribute="group") %} |
|
| 18 | + | ### {{ group | striptags | trim | upper_first }} |
|
| 19 | + | {% for commit in commits %} |
|
| 20 | + | - {% if commit.scope %}*({{ commit.scope }})* {% endif %}{% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\ |
|
| 21 | + | {% endfor %} |
|
| 22 | + | {% endfor %}\n |
|
| 23 | + | """ |
|
| 24 | + | trim = true |
|
| 25 | + | ||
| 26 | + | [git] |
|
| 27 | + | conventional_commits = true |
|
| 28 | + | filter_unconventional = true |
|
| 29 | + | split_commits = false |
|
| 30 | + | commit_parsers = [ |
|
| 31 | + | { message = "^feat", group = "Features" }, |
|
| 32 | + | { message = "^fix", group = "Bug Fixes" }, |
|
| 33 | + | { message = "^docs", group = "Documentation" }, |
|
| 34 | + | { message = "^perf", group = "Performance" }, |
|
| 35 | + | { message = "^refactor", group = "Refactoring" }, |
|
| 36 | + | { message = "^style", group = "Styling" }, |
|
| 37 | + | { message = "^test", group = "Testing" }, |
|
| 38 | + | { message = "^chore\\(release\\)", skip = true }, |
|
| 39 | + | { message = "^chore\\(deps\\)", skip = true }, |
|
| 40 | + | { message = "^chore|^ci", group = "Miscellaneous" }, |
|
| 41 | + | ] |
|
| 42 | + | protect_breaking_commits = false |
|
| 43 | + | filter_commits = false |
|
| 44 | + | topo_order_commits = false |
|
| 45 | + | sort_commits = "oldest" |
|
| 46 | + | ||
| 47 | + | [remote.github] |
|
| 48 | + | owner = "stevedylandev" |
|
| 49 | + | repo = "sipp" |
| 1 | + | services: |
|
| 2 | + | sipp: |
|
| 3 | + | build: |
|
| 4 | + | context: ../.. |
|
| 5 | + | dockerfile: apps/sipp/Dockerfile |
|
| 6 | + | ports: |
|
| 7 | + | - "3000:3000" |
|
| 8 | + | environment: |
|
| 9 | + | - SIPP_API_KEY=${SIPP_API_KEY:-changeme} |
|
| 10 | + | - SIPP_AUTH_ENDPOINTS=api_delete,api_list |
|
| 11 | + | volumes: |
|
| 12 | + | - sipp-data:/data |
|
| 13 | + | restart: unless-stopped |
|
| 14 | + | ||
| 15 | + | volumes: |
|
| 16 | + | sipp-data: |
| 1 | + | <?xml version="1.0" encoding="UTF-8"?> |
|
| 2 | + | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|
| 3 | + | <plist version="1.0"> |
|
| 4 | + | <dict> |
|
| 5 | + | <!-- |
|
| 6 | + | The colors in this theme are encoded as #RRGGBBAA where: |
|
| 7 | + | * If AA is 00, then RR is an ANSI palette number from 00 to 07. |
|
| 8 | + | * If AA is 01, the terminal's default fg/bg color is used. |
|
| 9 | + | --> |
|
| 10 | + | <key>author</key> |
|
| 11 | + | <string>Template: Chris Kempson, Scheme: Mitchell Kember</string> |
|
| 12 | + | <key>name</key> |
|
| 13 | + | <string>ANSI</string> |
|
| 14 | + | <key>colorSpaceName</key> |
|
| 15 | + | <string>sRGB</string> |
|
| 16 | + | <key>settings</key> |
|
| 17 | + | <array> |
|
| 18 | + | <dict> |
|
| 19 | + | <key>settings</key> |
|
| 20 | + | <dict> |
|
| 21 | + | <key>background</key> |
|
| 22 | + | <string>#00000001</string> |
|
| 23 | + | <key>foreground</key> |
|
| 24 | + | <string>#00000001</string> |
|
| 25 | + | <!-- |
|
| 26 | + | Explicitly set the gutter color since bat falls back to a |
|
| 27 | + | hardcoded DEFAULT_GUTTER_COLOR otherwise. |
|
| 28 | + | --> |
|
| 29 | + | <key>gutter</key> |
|
| 30 | + | <string>#00000001</string> |
|
| 31 | + | <key>gutterForeground</key> |
|
| 32 | + | <string>#00000001</string> |
|
| 33 | + | </dict> |
|
| 34 | + | </dict> |
|
| 35 | + | <dict> |
|
| 36 | + | <key>name</key> |
|
| 37 | + | <string>Comments</string> |
|
| 38 | + | <key>scope</key> |
|
| 39 | + | <string>comment, punctuation.definition.comment</string> |
|
| 40 | + | <key>settings</key> |
|
| 41 | + | <dict> |
|
| 42 | + | <key>foreground</key> |
|
| 43 | + | <string>#02000000</string> |
|
| 44 | + | </dict> |
|
| 45 | + | </dict> |
|
| 46 | + | <dict> |
|
| 47 | + | <key>name</key> |
|
| 48 | + | <string>Keywords</string> |
|
| 49 | + | <key>scope</key> |
|
| 50 | + | <string>keyword</string> |
|
| 51 | + | <key>settings</key> |
|
| 52 | + | <dict> |
|
| 53 | + | <key>foreground</key> |
|
| 54 | + | <string>#05000000</string> |
|
| 55 | + | </dict> |
|
| 56 | + | </dict> |
|
| 57 | + | <dict> |
|
| 58 | + | <key>name</key> |
|
| 59 | + | <string>Functions</string> |
|
| 60 | + | <key>scope</key> |
|
| 61 | + | <string>entity.name.function, meta.require, support.function.any-method</string> |
|
| 62 | + | <key>settings</key> |
|
| 63 | + | <dict> |
|
| 64 | + | <key>foreground</key> |
|
| 65 | + | <string>#04000000</string> |
|
| 66 | + | </dict> |
|
| 67 | + | </dict> |
|
| 68 | + | <dict> |
|
| 69 | + | <key>name</key> |
|
| 70 | + | <string>Labels</string> |
|
| 71 | + | <key>scope</key> |
|
| 72 | + | <string>entity.name.label, variable.parameter</string> |
|
| 73 | + | <key>settings</key> |
|
| 74 | + | <dict> |
|
| 75 | + | <key>foreground</key> |
|
| 76 | + | <string>#06000000</string> |
|
| 77 | + | </dict> |
|
| 78 | + | </dict> |
|
| 79 | + | <dict> |
|
| 80 | + | <key>name</key> |
|
| 81 | + | <string>Classes</string> |
|
| 82 | + | <key>scope</key> |
|
| 83 | + | <string>support.class, entity.name.class, entity.name.type.class, entity.name</string> |
|
| 84 | + | <key>settings</key> |
|
| 85 | + | <dict> |
|
| 86 | + | <key>foreground</key> |
|
| 87 | + | <string>#03000000</string> |
|
| 88 | + | </dict> |
|
| 89 | + | </dict> |
|
| 90 | + | <dict> |
|
| 91 | + | <key>name</key> |
|
| 92 | + | <string>Methods</string> |
|
| 93 | + | <key>scope</key> |
|
| 94 | + | <string>keyword.other.special-method</string> |
|
| 95 | + | <key>settings</key> |
|
| 96 | + | <dict> |
|
| 97 | + | <key>foreground</key> |
|
| 98 | + | <string>#04000000</string> |
|
| 99 | + | </dict> |
|
| 100 | + | </dict> |
|
| 101 | + | <dict> |
|
| 102 | + | <key>name</key> |
|
| 103 | + | <string>Storage</string> |
|
| 104 | + | <key>scope</key> |
|
| 105 | + | <string>storage</string> |
|
| 106 | + | <key>settings</key> |
|
| 107 | + | <dict> |
|
| 108 | + | <key>foreground</key> |
|
| 109 | + | <string>#05000000</string> |
|
| 110 | + | </dict> |
|
| 111 | + | </dict> |
|
| 112 | + | <dict> |
|
| 113 | + | <key>name</key> |
|
| 114 | + | <string>Support</string> |
|
| 115 | + | <key>scope</key> |
|
| 116 | + | <string>support.function</string> |
|
| 117 | + | <key>settings</key> |
|
| 118 | + | <dict> |
|
| 119 | + | <key>foreground</key> |
|
| 120 | + | <string>#06000000</string> |
|
| 121 | + | </dict> |
|
| 122 | + | </dict> |
|
| 123 | + | <dict> |
|
| 124 | + | <key>name</key> |
|
| 125 | + | <string>Strings, Inherited Class</string> |
|
| 126 | + | <key>scope</key> |
|
| 127 | + | <string>string, constant.other.symbol, entity.other.inherited-class</string> |
|
| 128 | + | <key>settings</key> |
|
| 129 | + | <dict> |
|
| 130 | + | <key>foreground</key> |
|
| 131 | + | <string>#02000000</string> |
|
| 132 | + | </dict> |
|
| 133 | + | </dict> |
|
| 134 | + | <dict> |
|
| 135 | + | <key>name</key> |
|
| 136 | + | <string>Integers</string> |
|
| 137 | + | <key>scope</key> |
|
| 138 | + | <string>constant.numeric</string> |
|
| 139 | + | <key>settings</key> |
|
| 140 | + | <dict> |
|
| 141 | + | <key>foreground</key> |
|
| 142 | + | <string>#03000000</string> |
|
| 143 | + | </dict> |
|
| 144 | + | </dict> |
|
| 145 | + | <dict> |
|
| 146 | + | <key>name</key> |
|
| 147 | + | <string>Floats</string> |
|
| 148 | + | <key>scope</key> |
|
| 149 | + | <string>none</string> |
|
| 150 | + | <key>settings</key> |
|
| 151 | + | <dict> |
|
| 152 | + | <key>foreground</key> |
|
| 153 | + | <string>#03000000</string> |
|
| 154 | + | </dict> |
|
| 155 | + | </dict> |
|
| 156 | + | <dict> |
|
| 157 | + | <key>name</key> |
|
| 158 | + | <string>Boolean</string> |
|
| 159 | + | <key>scope</key> |
|
| 160 | + | <string>none</string> |
|
| 161 | + | <key>settings</key> |
|
| 162 | + | <dict> |
|
| 163 | + | <key>foreground</key> |
|
| 164 | + | <string>#03000000</string> |
|
| 165 | + | </dict> |
|
| 166 | + | </dict> |
|
| 167 | + | <dict> |
|
| 168 | + | <key>name</key> |
|
| 169 | + | <string>Constants</string> |
|
| 170 | + | <key>scope</key> |
|
| 171 | + | <string>constant</string> |
|
| 172 | + | <key>settings</key> |
|
| 173 | + | <dict> |
|
| 174 | + | <key>foreground</key> |
|
| 175 | + | <string>#03000000</string> |
|
| 176 | + | </dict> |
|
| 177 | + | </dict> |
|
| 178 | + | <dict> |
|
| 179 | + | <key>name</key> |
|
| 180 | + | <string>Tags</string> |
|
| 181 | + | <key>scope</key> |
|
| 182 | + | <string>entity.name.tag</string> |
|
| 183 | + | <key>settings</key> |
|
| 184 | + | <dict> |
|
| 185 | + | <key>foreground</key> |
|
| 186 | + | <string>#01000000</string> |
|
| 187 | + | </dict> |
|
| 188 | + | </dict> |
|
| 189 | + | <dict> |
|
| 190 | + | <key>name</key> |
|
| 191 | + | <string>Attributes</string> |
|
| 192 | + | <key>scope</key> |
|
| 193 | + | <string>entity.other.attribute-name</string> |
|
| 194 | + | <key>settings</key> |
|
| 195 | + | <dict> |
|
| 196 | + | <key>foreground</key> |
|
| 197 | + | <string>#03000000</string> |
|
| 198 | + | </dict> |
|
| 199 | + | </dict> |
|
| 200 | + | <dict> |
|
| 201 | + | <key>name</key> |
|
| 202 | + | <string>Attribute IDs</string> |
|
| 203 | + | <key>scope</key> |
|
| 204 | + | <string>entity.other.attribute-name.id, punctuation.definition.entity</string> |
|
| 205 | + | <key>settings</key> |
|
| 206 | + | <dict> |
|
| 207 | + | <key>foreground</key> |
|
| 208 | + | <string>#04000000</string> |
|
| 209 | + | </dict> |
|
| 210 | + | </dict> |
|
| 211 | + | <dict> |
|
| 212 | + | <key>name</key> |
|
| 213 | + | <string>Selector</string> |
|
| 214 | + | <key>scope</key> |
|
| 215 | + | <string>meta.selector</string> |
|
| 216 | + | <key>settings</key> |
|
| 217 | + | <dict> |
|
| 218 | + | <key>foreground</key> |
|
| 219 | + | <string>#05000000</string> |
|
| 220 | + | </dict> |
|
| 221 | + | </dict> |
|
| 222 | + | <dict> |
|
| 223 | + | <key>name</key> |
|
| 224 | + | <string>Values</string> |
|
| 225 | + | <key>scope</key> |
|
| 226 | + | <string>none</string> |
|
| 227 | + | <key>settings</key> |
|
| 228 | + | <dict> |
|
| 229 | + | <key>foreground</key> |
|
| 230 | + | <string>#03000000</string> |
|
| 231 | + | </dict> |
|
| 232 | + | </dict> |
|
| 233 | + | <dict> |
|
| 234 | + | <key>name</key> |
|
| 235 | + | <string>Headings</string> |
|
| 236 | + | <key>scope</key> |
|
| 237 | + | <string>markup.heading punctuation.definition.heading, entity.name.section, markup.heading - text.html.markdown, meta.mapping.key string.quoted.double</string> |
|
| 238 | + | <key>settings</key> |
|
| 239 | + | <dict> |
|
| 240 | + | <key>fontStyle</key> |
|
| 241 | + | <string></string> |
|
| 242 | + | <key>foreground</key> |
|
| 243 | + | <string>#04000000</string> |
|
| 244 | + | </dict> |
|
| 245 | + | </dict> |
|
| 246 | + | <dict> |
|
| 247 | + | <key>name</key> |
|
| 248 | + | <string>Units</string> |
|
| 249 | + | <key>scope</key> |
|
| 250 | + | <string>keyword.other.unit</string> |
|
| 251 | + | <key>settings</key> |
|
| 252 | + | <dict> |
|
| 253 | + | <key>foreground</key> |
|
| 254 | + | <string>#03000000</string> |
|
| 255 | + | </dict> |
|
| 256 | + | </dict> |
|
| 257 | + | <dict> |
|
| 258 | + | <key>name</key> |
|
| 259 | + | <string>Bold</string> |
|
| 260 | + | <key>scope</key> |
|
| 261 | + | <string>markup.bold, punctuation.definition.bold</string> |
|
| 262 | + | <key>settings</key> |
|
| 263 | + | <dict> |
|
| 264 | + | <key>fontStyle</key> |
|
| 265 | + | <string>bold</string> |
|
| 266 | + | <key>foreground</key> |
|
| 267 | + | <string>#03000000</string> |
|
| 268 | + | </dict> |
|
| 269 | + | </dict> |
|
| 270 | + | <dict> |
|
| 271 | + | <key>name</key> |
|
| 272 | + | <string>Italic</string> |
|
| 273 | + | <key>scope</key> |
|
| 274 | + | <string>markup.italic, punctuation.definition.italic</string> |
|
| 275 | + | <key>settings</key> |
|
| 276 | + | <dict> |
|
| 277 | + | <key>fontStyle</key> |
|
| 278 | + | <string>italic</string> |
|
| 279 | + | <key>foreground</key> |
|
| 280 | + | <string>#05000000</string> |
|
| 281 | + | </dict> |
|
| 282 | + | </dict> |
|
| 283 | + | <dict> |
|
| 284 | + | <key>name</key> |
|
| 285 | + | <string>Code</string> |
|
| 286 | + | <key>scope</key> |
|
| 287 | + | <string>markup.raw.inline</string> |
|
| 288 | + | <key>settings</key> |
|
| 289 | + | <dict> |
|
| 290 | + | <key>foreground</key> |
|
| 291 | + | <string>#02000000</string> |
|
| 292 | + | </dict> |
|
| 293 | + | </dict> |
|
| 294 | + | <dict> |
|
| 295 | + | <key>name</key> |
|
| 296 | + | <string>Link Text</string> |
|
| 297 | + | <key>scope</key> |
|
| 298 | + | <string>string.other.link, punctuation.definition.string.end.markdown, punctuation.definition.string.begin.markdown</string> |
|
| 299 | + | <key>settings</key> |
|
| 300 | + | <dict> |
|
| 301 | + | <key>foreground</key> |
|
| 302 | + | <string>#01000000</string> |
|
| 303 | + | </dict> |
|
| 304 | + | </dict> |
|
| 305 | + | <dict> |
|
| 306 | + | <key>name</key> |
|
| 307 | + | <string>Link Url</string> |
|
| 308 | + | <key>scope</key> |
|
| 309 | + | <string>meta.link</string> |
|
| 310 | + | <key>settings</key> |
|
| 311 | + | <dict> |
|
| 312 | + | <key>foreground</key> |
|
| 313 | + | <string>#03000000</string> |
|
| 314 | + | </dict> |
|
| 315 | + | </dict> |
|
| 316 | + | <dict> |
|
| 317 | + | <key>name</key> |
|
| 318 | + | <string>Quotes</string> |
|
| 319 | + | <key>scope</key> |
|
| 320 | + | <string>markup.quote</string> |
|
| 321 | + | <key>settings</key> |
|
| 322 | + | <dict> |
|
| 323 | + | <key>foreground</key> |
|
| 324 | + | <string>#03000000</string> |
|
| 325 | + | </dict> |
|
| 326 | + | </dict> |
|
| 327 | + | <dict> |
|
| 328 | + | <key>name</key> |
|
| 329 | + | <string>Inserted</string> |
|
| 330 | + | <key>scope</key> |
|
| 331 | + | <string>markup.inserted</string> |
|
| 332 | + | <key>settings</key> |
|
| 333 | + | <dict> |
|
| 334 | + | <key>foreground</key> |
|
| 335 | + | <string>#02000000</string> |
|
| 336 | + | </dict> |
|
| 337 | + | </dict> |
|
| 338 | + | <dict> |
|
| 339 | + | <key>name</key> |
|
| 340 | + | <string>Deleted</string> |
|
| 341 | + | <key>scope</key> |
|
| 342 | + | <string>markup.deleted</string> |
|
| 343 | + | <key>settings</key> |
|
| 344 | + | <dict> |
|
| 345 | + | <key>foreground</key> |
|
| 346 | + | <string>#01000000</string> |
|
| 347 | + | </dict> |
|
| 348 | + | </dict> |
|
| 349 | + | <dict> |
|
| 350 | + | <key>name</key> |
|
| 351 | + | <string>Changed</string> |
|
| 352 | + | <key>scope</key> |
|
| 353 | + | <string>markup.changed</string> |
|
| 354 | + | <key>settings</key> |
|
| 355 | + | <dict> |
|
| 356 | + | <key>foreground</key> |
|
| 357 | + | <string>#05000000</string> |
|
| 358 | + | </dict> |
|
| 359 | + | </dict> |
|
| 360 | + | <dict> |
|
| 361 | + | <key>name</key> |
|
| 362 | + | <string>Colors</string> |
|
| 363 | + | <key>scope</key> |
|
| 364 | + | <string>constant.other.color</string> |
|
| 365 | + | <key>settings</key> |
|
| 366 | + | <dict> |
|
| 367 | + | <key>foreground</key> |
|
| 368 | + | <string>#06000000</string> |
|
| 369 | + | </dict> |
|
| 370 | + | </dict> |
|
| 371 | + | <dict> |
|
| 372 | + | <key>name</key> |
|
| 373 | + | <string>Regular Expressions</string> |
|
| 374 | + | <key>scope</key> |
|
| 375 | + | <string>string.regexp</string> |
|
| 376 | + | <key>settings</key> |
|
| 377 | + | <dict> |
|
| 378 | + | <key>foreground</key> |
|
| 379 | + | <string>#06000000</string> |
|
| 380 | + | </dict> |
|
| 381 | + | </dict> |
|
| 382 | + | <dict> |
|
| 383 | + | <key>name</key> |
|
| 384 | + | <string>Escape Characters</string> |
|
| 385 | + | <key>scope</key> |
|
| 386 | + | <string>constant.character.escape</string> |
|
| 387 | + | <key>settings</key> |
|
| 388 | + | <dict> |
|
| 389 | + | <key>foreground</key> |
|
| 390 | + | <string>#06000000</string> |
|
| 391 | + | </dict> |
|
| 392 | + | </dict> |
|
| 393 | + | <dict> |
|
| 394 | + | <key>name</key> |
|
| 395 | + | <string>Embedded</string> |
|
| 396 | + | <key>scope</key> |
|
| 397 | + | <string>punctuation.section.embedded, variable.interpolation</string> |
|
| 398 | + | <key>settings</key> |
|
| 399 | + | <dict> |
|
| 400 | + | <key>foreground</key> |
|
| 401 | + | <string>#05000000</string> |
|
| 402 | + | </dict> |
|
| 403 | + | </dict> |
|
| 404 | + | <dict> |
|
| 405 | + | <key>name</key> |
|
| 406 | + | <string>Illegal</string> |
|
| 407 | + | <key>scope</key> |
|
| 408 | + | <string>invalid.illegal</string> |
|
| 409 | + | <key>settings</key> |
|
| 410 | + | <dict> |
|
| 411 | + | <key>background</key> |
|
| 412 | + | <string>#01000000</string> |
|
| 413 | + | </dict> |
|
| 414 | + | </dict> |
|
| 415 | + | <dict> |
|
| 416 | + | <key>name</key> |
|
| 417 | + | <string>Broken</string> |
|
| 418 | + | <key>scope</key> |
|
| 419 | + | <string>invalid.broken</string> |
|
| 420 | + | <key>settings</key> |
|
| 421 | + | <dict> |
|
| 422 | + | <key>background</key> |
|
| 423 | + | <string>#03000000</string> |
|
| 424 | + | </dict> |
|
| 425 | + | </dict> |
|
| 426 | + | </array> |
|
| 427 | + | <key>uuid</key> |
|
| 428 | + | <string>uuid</string> |
|
| 429 | + | </dict> |
|
| 430 | + | </plist> |
| 1 | + | use crate::db::{self, Db, Snippet}; |
|
| 2 | + | use std::fmt; |
|
| 3 | + | ||
| 4 | + | #[derive(Debug)] |
|
| 5 | + | pub enum BackendError { |
|
| 6 | + | NotFound, |
|
| 7 | + | Unauthorized(String), |
|
| 8 | + | Network(String), |
|
| 9 | + | Database(String), |
|
| 10 | + | } |
|
| 11 | + | ||
| 12 | + | impl fmt::Display for BackendError { |
|
| 13 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
|
| 14 | + | match self { |
|
| 15 | + | BackendError::NotFound => write!(f, "Not found"), |
|
| 16 | + | BackendError::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg), |
|
| 17 | + | BackendError::Network(msg) => write!(f, "Network error: {}", msg), |
|
| 18 | + | BackendError::Database(msg) => write!(f, "Database error: {}", msg), |
|
| 19 | + | } |
|
| 20 | + | } |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | impl std::error::Error for BackendError {} |
|
| 24 | + | ||
| 25 | + | impl From<db::DbError> for BackendError { |
|
| 26 | + | fn from(e: db::DbError) -> Self { |
|
| 27 | + | BackendError::Database(e.to_string()) |
|
| 28 | + | } |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | pub enum Backend { |
|
| 32 | + | Local { |
|
| 33 | + | db: Db, |
|
| 34 | + | }, |
|
| 35 | + | Remote { |
|
| 36 | + | base_url: String, |
|
| 37 | + | api_key: Option<String>, |
|
| 38 | + | client: reqwest::blocking::Client, |
|
| 39 | + | }, |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | impl Backend { |
|
| 43 | + | pub fn local() -> Result<Self, BackendError> { |
|
| 44 | + | Ok(Backend::Local { db: db::init_db()? }) |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | pub fn remote(base_url: String, api_key: Option<String>) -> Self { |
|
| 48 | + | Backend::Remote { |
|
| 49 | + | base_url, |
|
| 50 | + | api_key, |
|
| 51 | + | client: reqwest::blocking::Client::new(), |
|
| 52 | + | } |
|
| 53 | + | } |
|
| 54 | + | ||
| 55 | + | pub fn list_snippets(&self) -> Result<Vec<Snippet>, BackendError> { |
|
| 56 | + | match self { |
|
| 57 | + | Backend::Local { db } => Ok(db::get_all_snippets(db)?), |
|
| 58 | + | Backend::Remote { |
|
| 59 | + | base_url, |
|
| 60 | + | api_key, |
|
| 61 | + | client, |
|
| 62 | + | } => { |
|
| 63 | + | let mut req = client.get(format!("{}/api/snippets", base_url)); |
|
| 64 | + | if let Some(key) = api_key { |
|
| 65 | + | req = req.header("x-api-key", key); |
|
| 66 | + | } |
|
| 67 | + | let resp = req.send().map_err(|e| BackendError::Network(e.to_string()))?; |
|
| 68 | + | match resp.status().as_u16() { |
|
| 69 | + | 200 => resp |
|
| 70 | + | .json::<Vec<Snippet>>() |
|
| 71 | + | .map_err(|e| BackendError::Network(e.to_string())), |
|
| 72 | + | 401 => Err(BackendError::Unauthorized("Invalid API key".into())), |
|
| 73 | + | 403 => Err(BackendError::Unauthorized("No API key configured on server".into())), |
|
| 74 | + | _ => Err(BackendError::Network(format!("HTTP {}", resp.status()))), |
|
| 75 | + | } |
|
| 76 | + | } |
|
| 77 | + | } |
|
| 78 | + | } |
|
| 79 | + | ||
| 80 | + | pub fn create_snippet(&self, name: &str, content: &str) -> Result<Snippet, BackendError> { |
|
| 81 | + | match self { |
|
| 82 | + | Backend::Local { db } => Ok(db::create_snippet(db, name, content)?), |
|
| 83 | + | Backend::Remote { |
|
| 84 | + | base_url, |
|
| 85 | + | api_key, |
|
| 86 | + | client, |
|
| 87 | + | } => { |
|
| 88 | + | let mut req = client |
|
| 89 | + | .post(format!("{}/api/snippets", base_url)) |
|
| 90 | + | .json(&serde_json::json!({"name": name, "content": content})); |
|
| 91 | + | if let Some(key) = api_key { |
|
| 92 | + | req = req.header("x-api-key", key); |
|
| 93 | + | } |
|
| 94 | + | let resp = req.send().map_err(|e| BackendError::Network(e.to_string()))?; |
|
| 95 | + | match resp.status().as_u16() { |
|
| 96 | + | 201 => resp |
|
| 97 | + | .json::<Snippet>() |
|
| 98 | + | .map_err(|e| BackendError::Network(e.to_string())), |
|
| 99 | + | 401 => Err(BackendError::Unauthorized("Invalid API key".into())), |
|
| 100 | + | 403 => Err(BackendError::Unauthorized("No API key configured on server".into())), |
|
| 101 | + | _ => Err(BackendError::Network(format!("HTTP {}", resp.status()))), |
|
| 102 | + | } |
|
| 103 | + | } |
|
| 104 | + | } |
|
| 105 | + | } |
|
| 106 | + | ||
| 107 | + | pub fn update_snippet( |
|
| 108 | + | &self, |
|
| 109 | + | short_id: &str, |
|
| 110 | + | name: &str, |
|
| 111 | + | content: &str, |
|
| 112 | + | ) -> Result<Option<Snippet>, BackendError> { |
|
| 113 | + | match self { |
|
| 114 | + | Backend::Local { db } => Ok(db::update_snippet_by_short_id(db, short_id, name, content)?), |
|
| 115 | + | Backend::Remote { |
|
| 116 | + | base_url, |
|
| 117 | + | api_key, |
|
| 118 | + | client, |
|
| 119 | + | } => { |
|
| 120 | + | let mut req = client |
|
| 121 | + | .put(format!("{}/api/snippets/{}", base_url, short_id)) |
|
| 122 | + | .json(&serde_json::json!({"name": name, "content": content})); |
|
| 123 | + | if let Some(key) = api_key { |
|
| 124 | + | req = req.header("x-api-key", key); |
|
| 125 | + | } |
|
| 126 | + | let resp = req.send().map_err(|e| BackendError::Network(e.to_string()))?; |
|
| 127 | + | match resp.status().as_u16() { |
|
| 128 | + | 200 => resp |
|
| 129 | + | .json::<Snippet>() |
|
| 130 | + | .map(Some) |
|
| 131 | + | .map_err(|e| BackendError::Network(e.to_string())), |
|
| 132 | + | 401 => Err(BackendError::Unauthorized("Invalid API key".into())), |
|
| 133 | + | 403 => Err(BackendError::Unauthorized("No API key configured on server".into())), |
|
| 134 | + | 404 => Ok(None), |
|
| 135 | + | _ => Err(BackendError::Network(format!("HTTP {}", resp.status()))), |
|
| 136 | + | } |
|
| 137 | + | } |
|
| 138 | + | } |
|
| 139 | + | } |
|
| 140 | + | ||
| 141 | + | pub fn delete_snippet(&self, short_id: &str) -> Result<bool, BackendError> { |
|
| 142 | + | match self { |
|
| 143 | + | Backend::Local { db } => Ok(db::delete_snippet_by_short_id(db, short_id)?), |
|
| 144 | + | Backend::Remote { |
|
| 145 | + | base_url, |
|
| 146 | + | api_key, |
|
| 147 | + | client, |
|
| 148 | + | } => { |
|
| 149 | + | let mut req = |
|
| 150 | + | client.delete(format!("{}/api/snippets/{}", base_url, short_id)); |
|
| 151 | + | if let Some(key) = api_key { |
|
| 152 | + | req = req.header("x-api-key", key); |
|
| 153 | + | } |
|
| 154 | + | let resp = req.send().map_err(|e| BackendError::Network(e.to_string()))?; |
|
| 155 | + | match resp.status().as_u16() { |
|
| 156 | + | 200 => Ok(true), |
|
| 157 | + | 401 => Err(BackendError::Unauthorized("Invalid API key".into())), |
|
| 158 | + | 403 => Err(BackendError::Unauthorized("No API key configured on server".into())), |
|
| 159 | + | 404 => Ok(false), |
|
| 160 | + | _ => Err(BackendError::Network(format!("HTTP {}", resp.status()))), |
|
| 161 | + | } |
|
| 162 | + | } |
|
| 163 | + | } |
|
| 164 | + | } |
|
| 165 | + | } |
| 1 | + | use serde::{Deserialize, Serialize}; |
|
| 2 | + | use std::path::PathBuf; |
|
| 3 | + | ||
| 4 | + | #[derive(Debug, Default, Serialize, Deserialize)] |
|
| 5 | + | pub struct Config { |
|
| 6 | + | pub remote_url: Option<String>, |
|
| 7 | + | pub api_key: Option<String>, |
|
| 8 | + | } |
|
| 9 | + | ||
| 10 | + | pub fn config_path() -> PathBuf { |
|
| 11 | + | let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); |
|
| 12 | + | PathBuf::from(home).join(".config/sipp/config.toml") |
|
| 13 | + | } |
|
| 14 | + | ||
| 15 | + | pub fn load_config() -> Config { |
|
| 16 | + | let path = config_path(); |
|
| 17 | + | match std::fs::read_to_string(&path) { |
|
| 18 | + | Ok(contents) => toml::from_str(&contents).unwrap_or_default(), |
|
| 19 | + | Err(_) => Config::default(), |
|
| 20 | + | } |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | pub fn save_config(config: &Config) -> Result<(), Box<dyn std::error::Error>> { |
|
| 24 | + | let path = config_path(); |
|
| 25 | + | if let Some(parent) = path.parent() { |
|
| 26 | + | std::fs::create_dir_all(parent)?; |
|
| 27 | + | } |
|
| 28 | + | let contents = toml::to_string_pretty(config)?; |
|
| 29 | + | std::fs::write(&path, contents)?; |
|
| 30 | + | Ok(()) |
|
| 31 | + | } |
| 1 | + | <?xml version="1.0" encoding="UTF-8"?> |
|
| 2 | + | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|
| 3 | + | <plist version="1.0"> |
|
| 4 | + | <dict> |
|
| 5 | + | <key>author</key> |
|
| 6 | + | <string>Template: Chris Kempson, Scheme: metalelf0 (https://github.com/metalelf0)</string> |
|
| 7 | + | <key>name</key> |
|
| 8 | + | <string>Base16 Black Metal (Bathory)</string> |
|
| 9 | + | <key>semanticClass</key> |
|
| 10 | + | <string>theme.base16.black-metal-bathory</string> |
|
| 11 | + | <key>colorSpaceName</key> |
|
| 12 | + | <string>sRGB</string> |
|
| 13 | + | <key>gutterSettings</key> |
|
| 14 | + | <dict> |
|
| 15 | + | <key>background</key> |
|
| 16 | + | <string>#121212</string> |
|
| 17 | + | <key>divider</key> |
|
| 18 | + | <string>#121212</string> |
|
| 19 | + | <key>foreground</key> |
|
| 20 | + | <string>#333333</string> |
|
| 21 | + | <key>selectionBackground</key> |
|
| 22 | + | <string>#222222</string> |
|
| 23 | + | <key>selectionForeground</key> |
|
| 24 | + | <string>#999999</string> |
|
| 25 | + | </dict> |
|
| 26 | + | <key>settings</key> |
|
| 27 | + | <array> |
|
| 28 | + | <dict> |
|
| 29 | + | <key>settings</key> |
|
| 30 | + | <dict> |
|
| 31 | + | <key>background</key> |
|
| 32 | + | <string>#121113</string> |
|
| 33 | + | <key>caret</key> |
|
| 34 | + | <string>#c1c1c1</string> |
|
| 35 | + | <key>foreground</key> |
|
| 36 | + | <string>#c1c1c1</string> |
|
| 37 | + | <key>invisibles</key> |
|
| 38 | + | <string>#333333</string> |
|
| 39 | + | <key>lineHighlight</key> |
|
| 40 | + | <string>#33333355</string> |
|
| 41 | + | <key>selection</key> |
|
| 42 | + | <string>#222222</string> |
|
| 43 | + | </dict> |
|
| 44 | + | </dict> |
|
| 45 | + | <dict> |
|
| 46 | + | <key>name</key> |
|
| 47 | + | <string>Text</string> |
|
| 48 | + | <key>scope</key> |
|
| 49 | + | <string>variable.parameter.function</string> |
|
| 50 | + | <key>settings</key> |
|
| 51 | + | <dict> |
|
| 52 | + | <key>foreground</key> |
|
| 53 | + | <string>#c1c1c1</string> |
|
| 54 | + | </dict> |
|
| 55 | + | </dict> |
|
| 56 | + | <dict> |
|
| 57 | + | <key>name</key> |
|
| 58 | + | <string>Comments</string> |
|
| 59 | + | <key>scope</key> |
|
| 60 | + | <string>comment, punctuation.definition.comment</string> |
|
| 61 | + | <key>settings</key> |
|
| 62 | + | <dict> |
|
| 63 | + | <key>foreground</key> |
|
| 64 | + | <string>#333333</string> |
|
| 65 | + | </dict> |
|
| 66 | + | </dict> |
|
| 67 | + | <dict> |
|
| 68 | + | <key>name</key> |
|
| 69 | + | <string>Punctuation</string> |
|
| 70 | + | <key>scope</key> |
|
| 71 | + | <string>punctuation.definition.string, punctuation.definition.variable, punctuation.definition.string, punctuation.definition.parameters, punctuation.definition.string, punctuation.definition.array</string> |
|
| 72 | + | <key>settings</key> |
|
| 73 | + | <dict> |
|
| 74 | + | <key>foreground</key> |
|
| 75 | + | <string>#c1c1c1</string> |
|
| 76 | + | </dict> |
|
| 77 | + | </dict> |
|
| 78 | + | <dict> |
|
| 79 | + | <key>name</key> |
|
| 80 | + | <string>Delimiters</string> |
|
| 81 | + | <key>scope</key> |
|
| 82 | + | <string>none</string> |
|
| 83 | + | <key>settings</key> |
|
| 84 | + | <dict> |
|
| 85 | + | <key>foreground</key> |
|
| 86 | + | <string>#c1c1c1</string> |
|
| 87 | + | </dict> |
|
| 88 | + | </dict> |
|
| 89 | + | <dict> |
|
| 90 | + | <key>name</key> |
|
| 91 | + | <string>Operators</string> |
|
| 92 | + | <key>scope</key> |
|
| 93 | + | <string>keyword.operator</string> |
|
| 94 | + | <key>settings</key> |
|
| 95 | + | <dict> |
|
| 96 | + | <key>foreground</key> |
|
| 97 | + | <string>#c1c1c1</string> |
|
| 98 | + | </dict> |
|
| 99 | + | </dict> |
|
| 100 | + | <dict> |
|
| 101 | + | <key>name</key> |
|
| 102 | + | <string>Keywords</string> |
|
| 103 | + | <key>scope</key> |
|
| 104 | + | <string>keyword</string> |
|
| 105 | + | <key>settings</key> |
|
| 106 | + | <dict> |
|
| 107 | + | <key>foreground</key> |
|
| 108 | + | <string>#999999</string> |
|
| 109 | + | </dict> |
|
| 110 | + | </dict> |
|
| 111 | + | <dict> |
|
| 112 | + | <key>name</key> |
|
| 113 | + | <string>Variables</string> |
|
| 114 | + | <key>scope</key> |
|
| 115 | + | <string>variable</string> |
|
| 116 | + | <key>settings</key> |
|
| 117 | + | <dict> |
|
| 118 | + | <key>foreground</key> |
|
| 119 | + | <string>#5f8787</string> |
|
| 120 | + | </dict> |
|
| 121 | + | </dict> |
|
| 122 | + | <dict> |
|
| 123 | + | <key>name</key> |
|
| 124 | + | <string>Functions</string> |
|
| 125 | + | <key>scope</key> |
|
| 126 | + | <string>entity.name.function, meta.require, support.function.any-method, variable.function, variable.annotation, support.macro</string> |
|
| 127 | + | <key>settings</key> |
|
| 128 | + | <dict> |
|
| 129 | + | <key>foreground</key> |
|
| 130 | + | <string>#888888</string> |
|
| 131 | + | </dict> |
|
| 132 | + | </dict> |
|
| 133 | + | <dict> |
|
| 134 | + | <key>name</key> |
|
| 135 | + | <string>Labels</string> |
|
| 136 | + | <key>scope</key> |
|
| 137 | + | <string>entity.name.label</string> |
|
| 138 | + | <key>settings</key> |
|
| 139 | + | <dict> |
|
| 140 | + | <key>foreground</key> |
|
| 141 | + | <string>#444444</string> |
|
| 142 | + | </dict> |
|
| 143 | + | </dict> |
|
| 144 | + | <dict> |
|
| 145 | + | <key>name</key> |
|
| 146 | + | <string>Classes</string> |
|
| 147 | + | <key>scope</key> |
|
| 148 | + | <string>support.class, entity.name.class, entity.name.type.class</string> |
|
| 149 | + | <key>settings</key> |
|
| 150 | + | <dict> |
|
| 151 | + | <key>foreground</key> |
|
| 152 | + | <string>#e78a53</string> |
|
| 153 | + | </dict> |
|
| 154 | + | </dict> |
|
| 155 | + | <dict> |
|
| 156 | + | <key>name</key> |
|
| 157 | + | <string>Classes</string> |
|
| 158 | + | <key>scope</key> |
|
| 159 | + | <string>meta.class</string> |
|
| 160 | + | <key>settings</key> |
|
| 161 | + | <dict> |
|
| 162 | + | <key>foreground</key> |
|
| 163 | + | <string>#c1c1c1</string> |
|
| 164 | + | </dict> |
|
| 165 | + | </dict> |
|
| 166 | + | <dict> |
|
| 167 | + | <key>name</key> |
|
| 168 | + | <string>Methods</string> |
|
| 169 | + | <key>scope</key> |
|
| 170 | + | <string>keyword.other.special-method</string> |
|
| 171 | + | <key>settings</key> |
|
| 172 | + | <dict> |
|
| 173 | + | <key>foreground</key> |
|
| 174 | + | <string>#888888</string> |
|
| 175 | + | </dict> |
|
| 176 | + | </dict> |
|
| 177 | + | <dict> |
|
| 178 | + | <key>name</key> |
|
| 179 | + | <string>Storage</string> |
|
| 180 | + | <key>scope</key> |
|
| 181 | + | <string>storage</string> |
|
| 182 | + | <key>settings</key> |
|
| 183 | + | <dict> |
|
| 184 | + | <key>foreground</key> |
|
| 185 | + | <string>#999999</string> |
|
| 186 | + | </dict> |
|
| 187 | + | </dict> |
|
| 188 | + | <dict> |
|
| 189 | + | <key>name</key> |
|
| 190 | + | <string>Support</string> |
|
| 191 | + | <key>scope</key> |
|
| 192 | + | <string>support.function</string> |
|
| 193 | + | <key>settings</key> |
|
| 194 | + | <dict> |
|
| 195 | + | <key>foreground</key> |
|
| 196 | + | <string>#aaaaaa</string> |
|
| 197 | + | </dict> |
|
| 198 | + | </dict> |
|
| 199 | + | <dict> |
|
| 200 | + | <key>name</key> |
|
| 201 | + | <string>Strings, Inherited Class</string> |
|
| 202 | + | <key>scope</key> |
|
| 203 | + | <string>string, constant.other.symbol, entity.other.inherited-class</string> |
|
| 204 | + | <key>settings</key> |
|
| 205 | + | <dict> |
|
| 206 | + | <key>foreground</key> |
|
| 207 | + | <string>#fbcb97</string> |
|
| 208 | + | </dict> |
|
| 209 | + | </dict> |
|
| 210 | + | <dict> |
|
| 211 | + | <key>name</key> |
|
| 212 | + | <string>Integers</string> |
|
| 213 | + | <key>scope</key> |
|
| 214 | + | <string>constant.numeric</string> |
|
| 215 | + | <key>settings</key> |
|
| 216 | + | <dict> |
|
| 217 | + | <key>foreground</key> |
|
| 218 | + | <string>#aaaaaa</string> |
|
| 219 | + | </dict> |
|
| 220 | + | </dict> |
|
| 221 | + | <dict> |
|
| 222 | + | <key>name</key> |
|
| 223 | + | <string>Floats</string> |
|
| 224 | + | <key>scope</key> |
|
| 225 | + | <string>none</string> |
|
| 226 | + | <key>settings</key> |
|
| 227 | + | <dict> |
|
| 228 | + | <key>foreground</key> |
|
| 229 | + | <string>#aaaaaa</string> |
|
| 230 | + | </dict> |
|
| 231 | + | </dict> |
|
| 232 | + | <dict> |
|
| 233 | + | <key>name</key> |
|
| 234 | + | <string>Boolean</string> |
|
| 235 | + | <key>scope</key> |
|
| 236 | + | <string>none</string> |
|
| 237 | + | <key>settings</key> |
|
| 238 | + | <dict> |
|
| 239 | + | <key>foreground</key> |
|
| 240 | + | <string>#aaaaaa</string> |
|
| 241 | + | </dict> |
|
| 242 | + | </dict> |
|
| 243 | + | <dict> |
|
| 244 | + | <key>name</key> |
|
| 245 | + | <string>Constants</string> |
|
| 246 | + | <key>scope</key> |
|
| 247 | + | <string>constant</string> |
|
| 248 | + | <key>settings</key> |
|
| 249 | + | <dict> |
|
| 250 | + | <key>foreground</key> |
|
| 251 | + | <string>#aaaaaa</string> |
|
| 252 | + | </dict> |
|
| 253 | + | </dict> |
|
| 254 | + | <dict> |
|
| 255 | + | <key>name</key> |
|
| 256 | + | <string>Tags</string> |
|
| 257 | + | <key>scope</key> |
|
| 258 | + | <string>entity.name.tag</string> |
|
| 259 | + | <key>settings</key> |
|
| 260 | + | <dict> |
|
| 261 | + | <key>foreground</key> |
|
| 262 | + | <string>#5f8787</string> |
|
| 263 | + | </dict> |
|
| 264 | + | </dict> |
|
| 265 | + | <dict> |
|
| 266 | + | <key>name</key> |
|
| 267 | + | <string>Attributes</string> |
|
| 268 | + | <key>scope</key> |
|
| 269 | + | <string>entity.other.attribute-name</string> |
|
| 270 | + | <key>settings</key> |
|
| 271 | + | <dict> |
|
| 272 | + | <key>foreground</key> |
|
| 273 | + | <string>#aaaaaa</string> |
|
| 274 | + | </dict> |
|
| 275 | + | </dict> |
|
| 276 | + | <dict> |
|
| 277 | + | <key>name</key> |
|
| 278 | + | <string>Attribute IDs</string> |
|
| 279 | + | <key>scope</key> |
|
| 280 | + | <string>entity.other.attribute-name.id, punctuation.definition.entity</string> |
|
| 281 | + | <key>settings</key> |
|
| 282 | + | <dict> |
|
| 283 | + | <key>foreground</key> |
|
| 284 | + | <string>#888888</string> |
|
| 285 | + | </dict> |
|
| 286 | + | </dict> |
|
| 287 | + | <dict> |
|
| 288 | + | <key>name</key> |
|
| 289 | + | <string>Selector</string> |
|
| 290 | + | <key>scope</key> |
|
| 291 | + | <string>meta.selector</string> |
|
| 292 | + | <key>settings</key> |
|
| 293 | + | <dict> |
|
| 294 | + | <key>foreground</key> |
|
| 295 | + | <string>#999999</string> |
|
| 296 | + | </dict> |
|
| 297 | + | </dict> |
|
| 298 | + | <dict> |
|
| 299 | + | <key>name</key> |
|
| 300 | + | <string>Values</string> |
|
| 301 | + | <key>scope</key> |
|
| 302 | + | <string>none</string> |
|
| 303 | + | <key>settings</key> |
|
| 304 | + | <dict> |
|
| 305 | + | <key>foreground</key> |
|
| 306 | + | <string>#aaaaaa</string> |
|
| 307 | + | </dict> |
|
| 308 | + | </dict> |
|
| 309 | + | <dict> |
|
| 310 | + | <key>name</key> |
|
| 311 | + | <string>Headings</string> |
|
| 312 | + | <key>scope</key> |
|
| 313 | + | <string>markup.heading punctuation.definition.heading, entity.name.section</string> |
|
| 314 | + | <key>settings</key> |
|
| 315 | + | <dict> |
|
| 316 | + | <key>fontStyle</key> |
|
| 317 | + | <string></string> |
|
| 318 | + | <key>foreground</key> |
|
| 319 | + | <string>#888888</string> |
|
| 320 | + | </dict> |
|
| 321 | + | </dict> |
|
| 322 | + | <dict> |
|
| 323 | + | <key>name</key> |
|
| 324 | + | <string>Units</string> |
|
| 325 | + | <key>scope</key> |
|
| 326 | + | <string>keyword.other.unit</string> |
|
| 327 | + | <key>settings</key> |
|
| 328 | + | <dict> |
|
| 329 | + | <key>foreground</key> |
|
| 330 | + | <string>#aaaaaa</string> |
|
| 331 | + | </dict> |
|
| 332 | + | </dict> |
|
| 333 | + | <dict> |
|
| 334 | + | <key>name</key> |
|
| 335 | + | <string>Bold</string> |
|
| 336 | + | <key>scope</key> |
|
| 337 | + | <string>markup.bold, punctuation.definition.bold</string> |
|
| 338 | + | <key>settings</key> |
|
| 339 | + | <dict> |
|
| 340 | + | <key>fontStyle</key> |
|
| 341 | + | <string>bold</string> |
|
| 342 | + | <key>foreground</key> |
|
| 343 | + | <string>#e78a53</string> |
|
| 344 | + | </dict> |
|
| 345 | + | </dict> |
|
| 346 | + | <dict> |
|
| 347 | + | <key>name</key> |
|
| 348 | + | <string>Italic</string> |
|
| 349 | + | <key>scope</key> |
|
| 350 | + | <string>markup.italic, punctuation.definition.italic</string> |
|
| 351 | + | <key>settings</key> |
|
| 352 | + | <dict> |
|
| 353 | + | <key>fontStyle</key> |
|
| 354 | + | <string>italic</string> |
|
| 355 | + | <key>foreground</key> |
|
| 356 | + | <string>#999999</string> |
|
| 357 | + | </dict> |
|
| 358 | + | </dict> |
|
| 359 | + | <dict> |
|
| 360 | + | <key>name</key> |
|
| 361 | + | <string>Code</string> |
|
| 362 | + | <key>scope</key> |
|
| 363 | + | <string>markup.raw.inline</string> |
|
| 364 | + | <key>settings</key> |
|
| 365 | + | <dict> |
|
| 366 | + | <key>foreground</key> |
|
| 367 | + | <string>#fbcb97</string> |
|
| 368 | + | </dict> |
|
| 369 | + | </dict> |
|
| 370 | + | <dict> |
|
| 371 | + | <key>name</key> |
|
| 372 | + | <string>Link Text</string> |
|
| 373 | + | <key>scope</key> |
|
| 374 | + | <string>string.other.link, punctuation.definition.string.end.markdown, punctuation.definition.string.begin.markdown</string> |
|
| 375 | + | <key>settings</key> |
|
| 376 | + | <dict> |
|
| 377 | + | <key>foreground</key> |
|
| 378 | + | <string>#5f8787</string> |
|
| 379 | + | </dict> |
|
| 380 | + | </dict> |
|
| 381 | + | <dict> |
|
| 382 | + | <key>name</key> |
|
| 383 | + | <string>Link Url</string> |
|
| 384 | + | <key>scope</key> |
|
| 385 | + | <string>meta.link</string> |
|
| 386 | + | <key>settings</key> |
|
| 387 | + | <dict> |
|
| 388 | + | <key>foreground</key> |
|
| 389 | + | <string>#aaaaaa</string> |
|
| 390 | + | </dict> |
|
| 391 | + | </dict> |
|
| 392 | + | <dict> |
|
| 393 | + | <key>name</key> |
|
| 394 | + | <string>Lists</string> |
|
| 395 | + | <key>scope</key> |
|
| 396 | + | <string>markup.list</string> |
|
| 397 | + | <key>settings</key> |
|
| 398 | + | <dict> |
|
| 399 | + | <key>foreground</key> |
|
| 400 | + | <string>#5f8787</string> |
|
| 401 | + | </dict> |
|
| 402 | + | </dict> |
|
| 403 | + | <dict> |
|
| 404 | + | <key>name</key> |
|
| 405 | + | <string>Quotes</string> |
|
| 406 | + | <key>scope</key> |
|
| 407 | + | <string>markup.quote</string> |
|
| 408 | + | <key>settings</key> |
|
| 409 | + | <dict> |
|
| 410 | + | <key>foreground</key> |
|
| 411 | + | <string>#aaaaaa</string> |
|
| 412 | + | </dict> |
|
| 413 | + | </dict> |
|
| 414 | + | <dict> |
|
| 415 | + | <key>name</key> |
|
| 416 | + | <string>Separator</string> |
|
| 417 | + | <key>scope</key> |
|
| 418 | + | <string>meta.separator</string> |
|
| 419 | + | <key>settings</key> |
|
| 420 | + | <dict> |
|
| 421 | + | <key>background</key> |
|
| 422 | + | <string>#222222</string> |
|
| 423 | + | <key>foreground</key> |
|
| 424 | + | <string>#c1c1c1</string> |
|
| 425 | + | </dict> |
|
| 426 | + | </dict> |
|
| 427 | + | <dict> |
|
| 428 | + | <key>name</key> |
|
| 429 | + | <string>Inserted</string> |
|
| 430 | + | <key>scope</key> |
|
| 431 | + | <string>markup.inserted</string> |
|
| 432 | + | <key>settings</key> |
|
| 433 | + | <dict> |
|
| 434 | + | <key>foreground</key> |
|
| 435 | + | <string>#fbcb97</string> |
|
| 436 | + | </dict> |
|
| 437 | + | </dict> |
|
| 438 | + | <dict> |
|
| 439 | + | <key>name</key> |
|
| 440 | + | <string>Deleted</string> |
|
| 441 | + | <key>scope</key> |
|
| 442 | + | <string>markup.deleted</string> |
|
| 443 | + | <key>settings</key> |
|
| 444 | + | <dict> |
|
| 445 | + | <key>foreground</key> |
|
| 446 | + | <string>#5f8787</string> |
|
| 447 | + | </dict> |
|
| 448 | + | </dict> |
|
| 449 | + | <dict> |
|
| 450 | + | <key>name</key> |
|
| 451 | + | <string>Changed</string> |
|
| 452 | + | <key>scope</key> |
|
| 453 | + | <string>markup.changed</string> |
|
| 454 | + | <key>settings</key> |
|
| 455 | + | <dict> |
|
| 456 | + | <key>foreground</key> |
|
| 457 | + | <string>#999999</string> |
|
| 458 | + | </dict> |
|
| 459 | + | </dict> |
|
| 460 | + | <dict> |
|
| 461 | + | <key>name</key> |
|
| 462 | + | <string>Colors</string> |
|
| 463 | + | <key>scope</key> |
|
| 464 | + | <string>constant.other.color</string> |
|
| 465 | + | <key>settings</key> |
|
| 466 | + | <dict> |
|
| 467 | + | <key>foreground</key> |
|
| 468 | + | <string>#aaaaaa</string> |
|
| 469 | + | </dict> |
|
| 470 | + | </dict> |
|
| 471 | + | <dict> |
|
| 472 | + | <key>name</key> |
|
| 473 | + | <string>Regular Expressions</string> |
|
| 474 | + | <key>scope</key> |
|
| 475 | + | <string>string.regexp</string> |
|
| 476 | + | <key>settings</key> |
|
| 477 | + | <dict> |
|
| 478 | + | <key>foreground</key> |
|
| 479 | + | <string>#aaaaaa</string> |
|
| 480 | + | </dict> |
|
| 481 | + | </dict> |
|
| 482 | + | <dict> |
|
| 483 | + | <key>name</key> |
|
| 484 | + | <string>Escape Characters</string> |
|
| 485 | + | <key>scope</key> |
|
| 486 | + | <string>constant.character.escape</string> |
|
| 487 | + | <key>settings</key> |
|
| 488 | + | <dict> |
|
| 489 | + | <key>foreground</key> |
|
| 490 | + | <string>#aaaaaa</string> |
|
| 491 | + | </dict> |
|
| 492 | + | </dict> |
|
| 493 | + | <dict> |
|
| 494 | + | <key>name</key> |
|
| 495 | + | <string>Embedded</string> |
|
| 496 | + | <key>scope</key> |
|
| 497 | + | <string>punctuation.section.embedded, variable.interpolation</string> |
|
| 498 | + | <key>settings</key> |
|
| 499 | + | <dict> |
|
| 500 | + | <key>foreground</key> |
|
| 501 | + | <string>#999999</string> |
|
| 502 | + | </dict> |
|
| 503 | + | </dict> |
|
| 504 | + | <dict> |
|
| 505 | + | <key>name</key> |
|
| 506 | + | <string>Illegal</string> |
|
| 507 | + | <key>scope</key> |
|
| 508 | + | <string>invalid.illegal</string> |
|
| 509 | + | <key>settings</key> |
|
| 510 | + | <dict> |
|
| 511 | + | <key>background</key> |
|
| 512 | + | <string>#5f8787</string> |
|
| 513 | + | <key>foreground</key> |
|
| 514 | + | <string>#c1c1c1</string> |
|
| 515 | + | </dict> |
|
| 516 | + | </dict> |
|
| 517 | + | <dict> |
|
| 518 | + | <key>name</key> |
|
| 519 | + | <string>Broken</string> |
|
| 520 | + | <key>scope</key> |
|
| 521 | + | <string>invalid.broken</string> |
|
| 522 | + | <key>settings</key> |
|
| 523 | + | <dict> |
|
| 524 | + | <key>background</key> |
|
| 525 | + | <string>#aaaaaa</string> |
|
| 526 | + | <key>foreground</key> |
|
| 527 | + | <string>#121113</string> |
|
| 528 | + | </dict> |
|
| 529 | + | </dict> |
|
| 530 | + | <dict> |
|
| 531 | + | <key>name</key> |
|
| 532 | + | <string>Deprecated</string> |
|
| 533 | + | <key>scope</key> |
|
| 534 | + | <string>invalid.deprecated</string> |
|
| 535 | + | <key>settings</key> |
|
| 536 | + | <dict> |
|
| 537 | + | <key>background</key> |
|
| 538 | + | <string>#444444</string> |
|
| 539 | + | <key>foreground</key> |
|
| 540 | + | <string>#c1c1c1</string> |
|
| 541 | + | </dict> |
|
| 542 | + | </dict> |
|
| 543 | + | <dict> |
|
| 544 | + | <key>name</key> |
|
| 545 | + | <string>Unimplemented</string> |
|
| 546 | + | <key>scope</key> |
|
| 547 | + | <string>invalid.unimplemented</string> |
|
| 548 | + | <key>settings</key> |
|
| 549 | + | <dict> |
|
| 550 | + | <key>background</key> |
|
| 551 | + | <string>#333333</string> |
|
| 552 | + | <key>foreground</key> |
|
| 553 | + | <string>#c1c1c1</string> |
|
| 554 | + | </dict> |
|
| 555 | + | </dict> |
|
| 556 | + | </array> |
|
| 557 | + | <key>uuid</key> |
|
| 558 | + | <string>uuid</string> |
|
| 559 | + | </dict> |
|
| 560 | + | </plist> |
| 1 | + | use nanoid::nanoid; |
|
| 2 | + | use rusqlite::{Connection, params}; |
|
| 3 | + | use serde::{Deserialize, Serialize}; |
|
| 4 | + | use std::fmt; |
|
| 5 | + | use std::sync::{Arc, Mutex}; |
|
| 6 | + | ||
| 7 | + | pub type Db = Arc<Mutex<Connection>>; |
|
| 8 | + | ||
| 9 | + | #[derive(Debug)] |
|
| 10 | + | pub enum DbError { |
|
| 11 | + | Sqlite(rusqlite::Error), |
|
| 12 | + | LockPoisoned, |
|
| 13 | + | } |
|
| 14 | + | ||
| 15 | + | impl fmt::Display for DbError { |
|
| 16 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
|
| 17 | + | match self { |
|
| 18 | + | DbError::Sqlite(e) => write!(f, "Database error: {}", e), |
|
| 19 | + | DbError::LockPoisoned => write!(f, "Database lock poisoned"), |
|
| 20 | + | } |
|
| 21 | + | } |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | impl std::error::Error for DbError {} |
|
| 25 | + | ||
| 26 | + | impl From<rusqlite::Error> for DbError { |
|
| 27 | + | fn from(e: rusqlite::Error) -> Self { |
|
| 28 | + | DbError::Sqlite(e) |
|
| 29 | + | } |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | #[derive(Serialize, Deserialize)] |
|
| 33 | + | pub struct Snippet { |
|
| 34 | + | pub id: i64, |
|
| 35 | + | pub short_id: String, |
|
| 36 | + | pub content: String, |
|
| 37 | + | pub name: String, |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | fn generate_short_id() -> String { |
|
| 41 | + | nanoid!(10) |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | pub fn db_path() -> String { |
|
| 45 | + | std::env::var("SIPP_DB_PATH").unwrap_or_else(|_| "sipp.sqlite".to_string()) |
|
| 46 | + | } |
|
| 47 | + | ||
| 48 | + | pub fn init_db() -> Result<Db, DbError> { |
|
| 49 | + | let conn = Connection::open(db_path())?; |
|
| 50 | + | conn.execute( |
|
| 51 | + | "CREATE TABLE IF NOT EXISTS snippets ( |
|
| 52 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 53 | + | short_id TEXT NOT NULL UNIQUE, |
|
| 54 | + | content TEXT NOT NULL, |
|
| 55 | + | name TEXT NOT NULL |
|
| 56 | + | )", |
|
| 57 | + | [], |
|
| 58 | + | )?; |
|
| 59 | + | Ok(Arc::new(Mutex::new(conn))) |
|
| 60 | + | } |
|
| 61 | + | ||
| 62 | + | pub fn create_snippet(db: &Db, name: &str, content: &str) -> Result<Snippet, DbError> { |
|
| 63 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 64 | + | let short_id = generate_short_id(); |
|
| 65 | + | conn.execute( |
|
| 66 | + | "INSERT INTO snippets (short_id, content, name) VALUES (?1, ?2, ?3)", |
|
| 67 | + | params![short_id, content, name], |
|
| 68 | + | )?; |
|
| 69 | + | let id = conn.last_insert_rowid(); |
|
| 70 | + | Ok(Snippet { |
|
| 71 | + | id, |
|
| 72 | + | short_id, |
|
| 73 | + | content: content.to_string(), |
|
| 74 | + | name: name.to_string(), |
|
| 75 | + | }) |
|
| 76 | + | } |
|
| 77 | + | ||
| 78 | + | pub fn get_snippet_by_short_id(db: &Db, short_id: &str) -> Result<Option<Snippet>, DbError> { |
|
| 79 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 80 | + | match conn.query_row( |
|
| 81 | + | "SELECT id, short_id, content, name FROM snippets WHERE short_id = ?1", |
|
| 82 | + | params![short_id], |
|
| 83 | + | |row| { |
|
| 84 | + | Ok(Snippet { |
|
| 85 | + | id: row.get(0)?, |
|
| 86 | + | short_id: row.get(1)?, |
|
| 87 | + | content: row.get(2)?, |
|
| 88 | + | name: row.get(3)?, |
|
| 89 | + | }) |
|
| 90 | + | }, |
|
| 91 | + | ) { |
|
| 92 | + | Ok(snippet) => Ok(Some(snippet)), |
|
| 93 | + | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
|
| 94 | + | Err(e) => Err(DbError::Sqlite(e)), |
|
| 95 | + | } |
|
| 96 | + | } |
|
| 97 | + | ||
| 98 | + | pub fn get_all_snippets(db: &Db) -> Result<Vec<Snippet>, DbError> { |
|
| 99 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 100 | + | let mut stmt = conn |
|
| 101 | + | .prepare("SELECT id, short_id, content, name FROM snippets ORDER BY id DESC")?; |
|
| 102 | + | let snippets = stmt.query_map([], |row| { |
|
| 103 | + | Ok(Snippet { |
|
| 104 | + | id: row.get(0)?, |
|
| 105 | + | short_id: row.get(1)?, |
|
| 106 | + | content: row.get(2)?, |
|
| 107 | + | name: row.get(3)?, |
|
| 108 | + | }) |
|
| 109 | + | })? |
|
| 110 | + | .filter_map(|r| r.ok()) |
|
| 111 | + | .collect(); |
|
| 112 | + | Ok(snippets) |
|
| 113 | + | } |
|
| 114 | + | ||
| 115 | + | pub fn delete_snippet_by_short_id(db: &Db, short_id: &str) -> Result<bool, DbError> { |
|
| 116 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 117 | + | let rows_affected = conn.execute( |
|
| 118 | + | "DELETE FROM snippets WHERE short_id = ?1", |
|
| 119 | + | params![short_id], |
|
| 120 | + | )?; |
|
| 121 | + | Ok(rows_affected > 0) |
|
| 122 | + | } |
|
| 123 | + | ||
| 124 | + | pub fn update_snippet_by_short_id( |
|
| 125 | + | db: &Db, |
|
| 126 | + | short_id: &str, |
|
| 127 | + | name: &str, |
|
| 128 | + | content: &str, |
|
| 129 | + | ) -> Result<Option<Snippet>, DbError> { |
|
| 130 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 131 | + | let rows_affected = conn.execute( |
|
| 132 | + | "UPDATE snippets SET name = ?1, content = ?2 WHERE short_id = ?3", |
|
| 133 | + | params![name, content, short_id], |
|
| 134 | + | )?; |
|
| 135 | + | if rows_affected == 0 { |
|
| 136 | + | return Ok(None); |
|
| 137 | + | } |
|
| 138 | + | match conn.query_row( |
|
| 139 | + | "SELECT id, short_id, content, name FROM snippets WHERE short_id = ?1", |
|
| 140 | + | params![short_id], |
|
| 141 | + | |row| { |
|
| 142 | + | Ok(Snippet { |
|
| 143 | + | id: row.get(0)?, |
|
| 144 | + | short_id: row.get(1)?, |
|
| 145 | + | content: row.get(2)?, |
|
| 146 | + | name: row.get(3)?, |
|
| 147 | + | }) |
|
| 148 | + | }, |
|
| 149 | + | ) { |
|
| 150 | + | Ok(snippet) => Ok(Some(snippet)), |
|
| 151 | + | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
|
| 152 | + | Err(e) => Err(DbError::Sqlite(e)), |
|
| 153 | + | } |
|
| 154 | + | } |
| 1 | + | use std::io::Cursor; |
|
| 2 | + | use syntect::highlighting::{Theme, ThemeSet}; |
|
| 3 | + | use syntect::html::highlighted_html_for_string; |
|
| 4 | + | use syntect::parsing::SyntaxSet; |
|
| 5 | + | ||
| 6 | + | pub struct Highlighter { |
|
| 7 | + | syntax_set: SyntaxSet, |
|
| 8 | + | theme: Theme, |
|
| 9 | + | } |
|
| 10 | + | ||
| 11 | + | impl Highlighter { |
|
| 12 | + | pub fn new() -> Self { |
|
| 13 | + | let theme_data = include_bytes!("darkmatter.tmTheme"); |
|
| 14 | + | let theme = ThemeSet::load_from_reader(&mut Cursor::new(&theme_data[..])) |
|
| 15 | + | .expect("failed to load darkmatter theme"); |
|
| 16 | + | Self { |
|
| 17 | + | syntax_set: SyntaxSet::load_defaults_newlines(), |
|
| 18 | + | theme, |
|
| 19 | + | } |
|
| 20 | + | } |
|
| 21 | + | ||
| 22 | + | pub fn highlight(&self, name: &str, content: &str) -> String { |
|
| 23 | + | let raw_ext = name.rsplit('.').next().unwrap_or(""); |
|
| 24 | + | let ext = match raw_ext { |
|
| 25 | + | "ts" | "tsx" | "jsx" => "js", |
|
| 26 | + | other => other, |
|
| 27 | + | }; |
|
| 28 | + | let syntax = self |
|
| 29 | + | .syntax_set |
|
| 30 | + | .find_syntax_by_extension(ext) |
|
| 31 | + | .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text()); |
|
| 32 | + | highlighted_html_for_string(content, &self.syntax_set, syntax, &self.theme) |
|
| 33 | + | .unwrap_or_else(|_| { |
|
| 34 | + | let escaped = content |
|
| 35 | + | .replace('&', "&") |
|
| 36 | + | .replace('<', "<") |
|
| 37 | + | .replace('>', ">"); |
|
| 38 | + | format!("<pre>{}</pre>", escaped) |
|
| 39 | + | }) |
|
| 40 | + | } |
|
| 41 | + | } |
| 1 | + | pub mod backend; |
|
| 2 | + | pub mod config; |
|
| 3 | + | pub mod db; |
|
| 4 | + | pub mod highlight; |
|
| 5 | + | pub mod server; |
|
| 6 | + | pub mod tui; |
| 1 | + | use clap::{Parser, Subcommand}; |
|
| 2 | + | use std::path::PathBuf; |
|
| 3 | + | ||
| 4 | + | #[derive(Parser)] |
|
| 5 | + | #[command(name = "sipp", about = "Snippet manager — TUI, server, and CLI")] |
|
| 6 | + | struct Cli { |
|
| 7 | + | /// Remote server URL (e.g. http://localhost:3000) |
|
| 8 | + | #[arg(short, long, env = "SIPP_REMOTE_URL")] |
|
| 9 | + | remote: Option<String>, |
|
| 10 | + | ||
| 11 | + | /// API key for authenticated operations |
|
| 12 | + | #[arg(short = 'k', long, env = "SIPP_API_KEY")] |
|
| 13 | + | api_key: Option<String>, |
|
| 14 | + | ||
| 15 | + | /// File path to create a snippet from |
|
| 16 | + | #[arg(value_name = "FILE")] |
|
| 17 | + | file: Option<PathBuf>, |
|
| 18 | + | ||
| 19 | + | #[command(subcommand)] |
|
| 20 | + | command: Option<Commands>, |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | #[derive(Subcommand)] |
|
| 24 | + | enum Commands { |
|
| 25 | + | /// Start the web server |
|
| 26 | + | Server { |
|
| 27 | + | /// Port to listen on |
|
| 28 | + | #[arg(short, long, default_value_t = 3000)] |
|
| 29 | + | port: u16, |
|
| 30 | + | ||
| 31 | + | /// Host to bind to |
|
| 32 | + | #[arg(long, default_value = "localhost")] |
|
| 33 | + | host: String, |
|
| 34 | + | }, |
|
| 35 | + | /// Launch the interactive TUI |
|
| 36 | + | Tui { |
|
| 37 | + | /// Remote server URL (e.g. http://localhost:3000) |
|
| 38 | + | #[arg(short, long, env = "SIPP_REMOTE_URL")] |
|
| 39 | + | remote: Option<String>, |
|
| 40 | + | ||
| 41 | + | /// API key for authenticated operations |
|
| 42 | + | #[arg(short = 'k', long, env = "SIPP_API_KEY")] |
|
| 43 | + | api_key: Option<String>, |
|
| 44 | + | }, |
|
| 45 | + | /// Save remote URL and API key to config file |
|
| 46 | + | Auth, |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | fn main() -> Result<(), Box<dyn std::error::Error>> { |
|
| 50 | + | let cli = Cli::parse(); |
|
| 51 | + | ||
| 52 | + | match cli.command { |
|
| 53 | + | Some(Commands::Server { port, host }) => { |
|
| 54 | + | let rt = tokio::runtime::Runtime::new()?; |
|
| 55 | + | rt.block_on(sipp_so::server::run(host, port)); |
|
| 56 | + | } |
|
| 57 | + | Some(Commands::Tui { remote, api_key }) => { |
|
| 58 | + | sipp_so::tui::run_interactive(remote, api_key)?; |
|
| 59 | + | } |
|
| 60 | + | Some(Commands::Auth) => { |
|
| 61 | + | sipp_so::tui::run_auth()?; |
|
| 62 | + | } |
|
| 63 | + | None => { |
|
| 64 | + | if let Some(file) = cli.file { |
|
| 65 | + | sipp_so::tui::run_file_upload(cli.remote, cli.api_key, file)?; |
|
| 66 | + | } else { |
|
| 67 | + | sipp_so::tui::run_interactive(cli.remote, cli.api_key)?; |
|
| 68 | + | } |
|
| 69 | + | } |
|
| 70 | + | } |
|
| 71 | + | ||
| 72 | + | Ok(()) |
|
| 73 | + | } |
| 1 | + | use askama::Template; |
|
| 2 | + | use askama_web::WebTemplate; |
|
| 3 | + | use subtle::ConstantTimeEq; |
|
| 4 | + | use axum::{ |
|
| 5 | + | Form, Json, Router, |
|
| 6 | + | extract::{Path, Request, State}, |
|
| 7 | + | http::{HeaderMap, StatusCode, header}, |
|
| 8 | + | middleware::{self, Next}, |
|
| 9 | + | response::{Html, IntoResponse, Redirect, Response}, |
|
| 10 | + | routing::{delete, get, post, put}, |
|
| 11 | + | }; |
|
| 12 | + | use rust_embed::Embed; |
|
| 13 | + | use serde::Deserialize; |
|
| 14 | + | use crate::db::{self, Db, Snippet}; |
|
| 15 | + | use crate::highlight::Highlighter; |
|
| 16 | + | use std::collections::HashSet; |
|
| 17 | + | use std::sync::Arc; |
|
| 18 | + | ||
| 19 | + | #[derive(Embed)] |
|
| 20 | + | #[folder = "assets/"] |
|
| 21 | + | struct Assets; |
|
| 22 | + | ||
| 23 | + | #[derive(Embed)] |
|
| 24 | + | #[folder = "static/"] |
|
| 25 | + | struct Static; |
|
| 26 | + | ||
| 27 | + | #[derive(Clone)] |
|
| 28 | + | struct ServerConfig { |
|
| 29 | + | api_key: Option<String>, |
|
| 30 | + | auth_endpoints: HashSet<String>, |
|
| 31 | + | max_content_size: usize, |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | impl ServerConfig { |
|
| 35 | + | fn from_env() -> Self { |
|
| 36 | + | let api_key = std::env::var("SIPP_API_KEY").ok(); |
|
| 37 | + | let auth_endpoints = match std::env::var("SIPP_AUTH_ENDPOINTS") { |
|
| 38 | + | Ok(val) if val.trim().eq_ignore_ascii_case("none") => HashSet::new(), |
|
| 39 | + | Ok(val) => val.split(',').map(|s| s.trim().to_lowercase()).collect(), |
|
| 40 | + | Err(_) => ["api_delete", "api_list", "api_update"].iter().map(|s| s.to_string()).collect(), |
|
| 41 | + | }; |
|
| 42 | + | let max_content_size = std::env::var("SIPP_MAX_CONTENT_SIZE") |
|
| 43 | + | .ok() |
|
| 44 | + | .and_then(|v| v.parse().ok()) |
|
| 45 | + | .unwrap_or(512_000); |
|
| 46 | + | ServerConfig { api_key, auth_endpoints, max_content_size } |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | fn requires_auth(&self, name: &str) -> bool { |
|
| 50 | + | self.auth_endpoints.contains("all") || self.auth_endpoints.contains(name) |
|
| 51 | + | } |
|
| 52 | + | } |
|
| 53 | + | ||
| 54 | + | #[derive(Clone)] |
|
| 55 | + | struct AppState { |
|
| 56 | + | db: Db, |
|
| 57 | + | highlighter: Arc<Highlighter>, |
|
| 58 | + | server_config: ServerConfig, |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | #[derive(Template)] |
|
| 62 | + | #[template(path = "index.html")] |
|
| 63 | + | struct IndexTemplate; |
|
| 64 | + | ||
| 65 | + | #[derive(Template)] |
|
| 66 | + | #[template(path = "admin.html")] |
|
| 67 | + | struct AdminTemplate; |
|
| 68 | + | ||
| 69 | + | #[derive(Template)] |
|
| 70 | + | #[template(path = "snippet.html")] |
|
| 71 | + | struct SnippetTemplate { |
|
| 72 | + | name: String, |
|
| 73 | + | content: String, |
|
| 74 | + | highlighted_content: String, |
|
| 75 | + | } |
|
| 76 | + | ||
| 77 | + | #[derive(Deserialize)] |
|
| 78 | + | struct CreateSnippetForm { |
|
| 79 | + | name: String, |
|
| 80 | + | content: String, |
|
| 81 | + | } |
|
| 82 | + | ||
| 83 | + | async fn index() -> WebTemplate<IndexTemplate> { |
|
| 84 | + | WebTemplate(IndexTemplate) |
|
| 85 | + | } |
|
| 86 | + | ||
| 87 | + | async fn admin() -> WebTemplate<AdminTemplate> { |
|
| 88 | + | WebTemplate(AdminTemplate) |
|
| 89 | + | } |
|
| 90 | + | ||
| 91 | + | fn is_cli_user_agent(headers: &HeaderMap) -> bool { |
|
| 92 | + | headers |
|
| 93 | + | .get(header::USER_AGENT) |
|
| 94 | + | .and_then(|v| v.to_str().ok()) |
|
| 95 | + | .map(|ua| { |
|
| 96 | + | let ua = ua.to_lowercase(); |
|
| 97 | + | ua.starts_with("curl/") || ua.starts_with("wget/") || ua.starts_with("httpie/") |
|
| 98 | + | }) |
|
| 99 | + | .unwrap_or(false) |
|
| 100 | + | } |
|
| 101 | + | ||
| 102 | + | async fn view_snippet( |
|
| 103 | + | State(state): State<AppState>, |
|
| 104 | + | Path(short_id): Path<String>, |
|
| 105 | + | headers: HeaderMap, |
|
| 106 | + | ) -> Result<Response, (StatusCode, Html<String>)> { |
|
| 107 | + | match db::get_snippet_by_short_id(&state.db, &short_id) { |
|
| 108 | + | Ok(Some(snippet)) => { |
|
| 109 | + | if is_cli_user_agent(&headers) { |
|
| 110 | + | Ok(( |
|
| 111 | + | [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], |
|
| 112 | + | snippet.content, |
|
| 113 | + | ) |
|
| 114 | + | .into_response()) |
|
| 115 | + | } else { |
|
| 116 | + | let highlighted_content = |
|
| 117 | + | state.highlighter.highlight(&snippet.name, &snippet.content); |
|
| 118 | + | Ok(WebTemplate(SnippetTemplate { |
|
| 119 | + | name: snippet.name, |
|
| 120 | + | content: snippet.content, |
|
| 121 | + | highlighted_content, |
|
| 122 | + | }) |
|
| 123 | + | .into_response()) |
|
| 124 | + | } |
|
| 125 | + | } |
|
| 126 | + | Ok(None) => Err(( |
|
| 127 | + | StatusCode::NOT_FOUND, |
|
| 128 | + | Html("<h1>Snippet not found</h1>".to_string()), |
|
| 129 | + | )), |
|
| 130 | + | Err(_) => Err(( |
|
| 131 | + | StatusCode::INTERNAL_SERVER_ERROR, |
|
| 132 | + | Html("<h1>Internal server error</h1>".to_string()), |
|
| 133 | + | )), |
|
| 134 | + | } |
|
| 135 | + | } |
|
| 136 | + | ||
| 137 | + | async fn create_snippet( |
|
| 138 | + | State(state): State<AppState>, |
|
| 139 | + | Form(form): Form<CreateSnippetForm>, |
|
| 140 | + | ) -> Result<Redirect, (StatusCode, Html<String>)> { |
|
| 141 | + | if form.content.len() > state.server_config.max_content_size { |
|
| 142 | + | return Err(( |
|
| 143 | + | StatusCode::PAYLOAD_TOO_LARGE, |
|
| 144 | + | Html(format!( |
|
| 145 | + | "<h1>Content too large</h1><p>Maximum size is {} bytes</p>", |
|
| 146 | + | state.server_config.max_content_size |
|
| 147 | + | )), |
|
| 148 | + | )); |
|
| 149 | + | } |
|
| 150 | + | match db::create_snippet(&state.db, &form.name, &form.content) { |
|
| 151 | + | Ok(snippet) => Ok(Redirect::to(&format!("/s/{}", snippet.short_id))), |
|
| 152 | + | Err(_) => Err(( |
|
| 153 | + | StatusCode::INTERNAL_SERVER_ERROR, |
|
| 154 | + | Html("<h1>Internal server error</h1>".to_string()), |
|
| 155 | + | )), |
|
| 156 | + | } |
|
| 157 | + | } |
|
| 158 | + | ||
| 159 | + | async fn require_api_key( |
|
| 160 | + | State(state): State<AppState>, |
|
| 161 | + | headers: HeaderMap, |
|
| 162 | + | request: Request, |
|
| 163 | + | next: Next, |
|
| 164 | + | ) -> Result<Response, (StatusCode, Json<serde_json::Value>)> { |
|
| 165 | + | let server_key = match &state.server_config.api_key { |
|
| 166 | + | Some(k) => k, |
|
| 167 | + | None => return Err(( |
|
| 168 | + | StatusCode::FORBIDDEN, |
|
| 169 | + | Json(serde_json::json!({"error": "No API key configured on server"})), |
|
| 170 | + | )), |
|
| 171 | + | }; |
|
| 172 | + | let provided = headers |
|
| 173 | + | .get("x-api-key") |
|
| 174 | + | .and_then(|v| v.to_str().ok()); |
|
| 175 | + | match provided { |
|
| 176 | + | Some(k) if k.as_bytes().ct_eq(server_key.as_bytes()).into() => Ok(next.run(request).await), |
|
| 177 | + | _ => Err(( |
|
| 178 | + | StatusCode::UNAUTHORIZED, |
|
| 179 | + | Json(serde_json::json!({"error": "Invalid or missing API key"})), |
|
| 180 | + | )), |
|
| 181 | + | } |
|
| 182 | + | } |
|
| 183 | + | ||
| 184 | + | async fn api_list_snippets( |
|
| 185 | + | State(state): State<AppState>, |
|
| 186 | + | ) -> Result<Json<Vec<Snippet>>, (StatusCode, Json<serde_json::Value>)> { |
|
| 187 | + | match db::get_all_snippets(&state.db) { |
|
| 188 | + | Ok(snippets) => Ok(Json(snippets)), |
|
| 189 | + | Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Internal server error"})))), |
|
| 190 | + | } |
|
| 191 | + | } |
|
| 192 | + | ||
| 193 | + | async fn api_get_snippet( |
|
| 194 | + | State(state): State<AppState>, |
|
| 195 | + | Path(short_id): Path<String>, |
|
| 196 | + | ) -> Result<Json<Snippet>, (StatusCode, Json<serde_json::Value>)> { |
|
| 197 | + | match db::get_snippet_by_short_id(&state.db, &short_id) { |
|
| 198 | + | Ok(Some(snippet)) => Ok(Json(snippet)), |
|
| 199 | + | Ok(None) => Err((StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Snippet not found"})))), |
|
| 200 | + | Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Internal server error"})))), |
|
| 201 | + | } |
|
| 202 | + | } |
|
| 203 | + | ||
| 204 | + | #[derive(Deserialize)] |
|
| 205 | + | struct ApiCreateSnippet { |
|
| 206 | + | name: String, |
|
| 207 | + | content: String, |
|
| 208 | + | } |
|
| 209 | + | ||
| 210 | + | async fn api_create_snippet( |
|
| 211 | + | State(state): State<AppState>, |
|
| 212 | + | Json(body): Json<ApiCreateSnippet>, |
|
| 213 | + | ) -> Result<(StatusCode, Json<Snippet>), (StatusCode, Json<serde_json::Value>)> { |
|
| 214 | + | if body.content.len() > state.server_config.max_content_size { |
|
| 215 | + | return Err(( |
|
| 216 | + | StatusCode::PAYLOAD_TOO_LARGE, |
|
| 217 | + | Json(serde_json::json!({ |
|
| 218 | + | "error": format!("Content too large. Maximum size is {} bytes", state.server_config.max_content_size) |
|
| 219 | + | })), |
|
| 220 | + | )); |
|
| 221 | + | } |
|
| 222 | + | match db::create_snippet(&state.db, &body.name, &body.content) { |
|
| 223 | + | Ok(snippet) => Ok((StatusCode::CREATED, Json(snippet))), |
|
| 224 | + | Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Internal server error"})))), |
|
| 225 | + | } |
|
| 226 | + | } |
|
| 227 | + | ||
| 228 | + | async fn api_delete_snippet( |
|
| 229 | + | State(state): State<AppState>, |
|
| 230 | + | Path(short_id): Path<String>, |
|
| 231 | + | ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> { |
|
| 232 | + | match db::delete_snippet_by_short_id(&state.db, &short_id) { |
|
| 233 | + | Ok(true) => Ok(Json(serde_json::json!({"deleted": true}))), |
|
| 234 | + | Ok(false) => Err((StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Snippet not found"})))), |
|
| 235 | + | Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Internal server error"})))), |
|
| 236 | + | } |
|
| 237 | + | } |
|
| 238 | + | ||
| 239 | + | async fn api_update_snippet( |
|
| 240 | + | State(state): State<AppState>, |
|
| 241 | + | Path(short_id): Path<String>, |
|
| 242 | + | Json(body): Json<ApiCreateSnippet>, |
|
| 243 | + | ) -> Result<Json<Snippet>, (StatusCode, Json<serde_json::Value>)> { |
|
| 244 | + | if body.content.len() > state.server_config.max_content_size { |
|
| 245 | + | return Err(( |
|
| 246 | + | StatusCode::PAYLOAD_TOO_LARGE, |
|
| 247 | + | Json(serde_json::json!({ |
|
| 248 | + | "error": format!("Content too large. Maximum size is {} bytes", state.server_config.max_content_size) |
|
| 249 | + | })), |
|
| 250 | + | )); |
|
| 251 | + | } |
|
| 252 | + | match db::update_snippet_by_short_id(&state.db, &short_id, &body.name, &body.content) { |
|
| 253 | + | Ok(Some(snippet)) => Ok(Json(snippet)), |
|
| 254 | + | Ok(None) => Err((StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Snippet not found"})))), |
|
| 255 | + | Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Internal server error"})))), |
|
| 256 | + | } |
|
| 257 | + | } |
|
| 258 | + | ||
| 259 | + | fn build_api_routes(state: &AppState) -> Router<AppState> { |
|
| 260 | + | let config = &state.server_config; |
|
| 261 | + | ||
| 262 | + | let auth_layer = middleware::from_fn_with_state(state.clone(), require_api_key); |
|
| 263 | + | ||
| 264 | + | // /api/snippets — GET (api_list) and POST (api_create) |
|
| 265 | + | let list_authed = config.requires_auth("api_list"); |
|
| 266 | + | let create_authed = config.requires_auth("api_create"); |
|
| 267 | + | ||
| 268 | + | // /api/snippets/{short_id} — GET (api_get), PUT (api_update), and DELETE (api_delete) |
|
| 269 | + | let get_authed = config.requires_auth("api_get"); |
|
| 270 | + | let update_authed = config.requires_auth("api_update"); |
|
| 271 | + | let delete_authed = config.requires_auth("api_delete"); |
|
| 272 | + | ||
| 273 | + | // Build authed router |
|
| 274 | + | let mut authed = Router::new(); |
|
| 275 | + | if list_authed { |
|
| 276 | + | authed = authed.route("/api/snippets", get(api_list_snippets)); |
|
| 277 | + | } |
|
| 278 | + | if create_authed { |
|
| 279 | + | authed = authed.route("/api/snippets", post(api_create_snippet)); |
|
| 280 | + | } |
|
| 281 | + | if get_authed { |
|
| 282 | + | authed = authed.route("/api/snippets/{short_id}", get(api_get_snippet)); |
|
| 283 | + | } |
|
| 284 | + | if update_authed { |
|
| 285 | + | authed = authed.route("/api/snippets/{short_id}", put(api_update_snippet)); |
|
| 286 | + | } |
|
| 287 | + | if delete_authed { |
|
| 288 | + | authed = authed.route("/api/snippets/{short_id}", delete(api_delete_snippet)); |
|
| 289 | + | } |
|
| 290 | + | let authed = authed.route_layer(auth_layer); |
|
| 291 | + | ||
| 292 | + | // Build open router |
|
| 293 | + | let mut open = Router::new(); |
|
| 294 | + | if !list_authed { |
|
| 295 | + | open = open.route("/api/snippets", get(api_list_snippets)); |
|
| 296 | + | } |
|
| 297 | + | if !create_authed { |
|
| 298 | + | open = open.route("/api/snippets", post(api_create_snippet)); |
|
| 299 | + | } |
|
| 300 | + | if !get_authed { |
|
| 301 | + | open = open.route("/api/snippets/{short_id}", get(api_get_snippet)); |
|
| 302 | + | } |
|
| 303 | + | if !update_authed { |
|
| 304 | + | open = open.route("/api/snippets/{short_id}", put(api_update_snippet)); |
|
| 305 | + | } |
|
| 306 | + | if !delete_authed { |
|
| 307 | + | open = open.route("/api/snippets/{short_id}", delete(api_delete_snippet)); |
|
| 308 | + | } |
|
| 309 | + | ||
| 310 | + | authed.merge(open) |
|
| 311 | + | } |
|
| 312 | + | ||
| 313 | + | fn mime_from_path(path: &str) -> &'static str { |
|
| 314 | + | match path.rsplit('.').next().unwrap_or("") { |
|
| 315 | + | "css" => "text/css", |
|
| 316 | + | "js" => "application/javascript", |
|
| 317 | + | "html" => "text/html", |
|
| 318 | + | "png" => "image/png", |
|
| 319 | + | "ico" => "image/x-icon", |
|
| 320 | + | "svg" => "image/svg+xml", |
|
| 321 | + | "woff" => "font/woff", |
|
| 322 | + | "woff2" => "font/woff2", |
|
| 323 | + | "ttf" => "font/ttf", |
|
| 324 | + | "otf" => "font/otf", |
|
| 325 | + | "json" | "webmanifest" => "application/json", |
|
| 326 | + | "jpg" | "jpeg" => "image/jpeg", |
|
| 327 | + | _ => "application/octet-stream", |
|
| 328 | + | } |
|
| 329 | + | } |
|
| 330 | + | ||
| 331 | + | async fn serve_assets(Path(path): Path<String>) -> Response { |
|
| 332 | + | match Assets::get(&path) { |
|
| 333 | + | Some(file) => { |
|
| 334 | + | let mime = mime_from_path(&path); |
|
| 335 | + | ([(header::CONTENT_TYPE, mime)], file.data).into_response() |
|
| 336 | + | } |
|
| 337 | + | None => StatusCode::NOT_FOUND.into_response(), |
|
| 338 | + | } |
|
| 339 | + | } |
|
| 340 | + | ||
| 341 | + | async fn serve_static(Path(path): Path<String>) -> Response { |
|
| 342 | + | match Static::get(&path) { |
|
| 343 | + | Some(file) => { |
|
| 344 | + | let mime = mime_from_path(&path); |
|
| 345 | + | ([(header::CONTENT_TYPE, mime)], file.data).into_response() |
|
| 346 | + | } |
|
| 347 | + | None => StatusCode::NOT_FOUND.into_response(), |
|
| 348 | + | } |
|
| 349 | + | } |
|
| 350 | + | ||
| 351 | + | pub async fn run(host: String, port: u16) { |
|
| 352 | + | dotenvy::dotenv().ok(); |
|
| 353 | + | ||
| 354 | + | let server_config = ServerConfig::from_env(); |
|
| 355 | + | ||
| 356 | + | // Validate endpoint names |
|
| 357 | + | let known = ["api_list", "api_create", "api_get", "api_update", "api_delete", "all", "none"]; |
|
| 358 | + | for name in &server_config.auth_endpoints { |
|
| 359 | + | if !known.contains(&name.as_str()) { |
|
| 360 | + | eprintln!("Warning: unknown auth endpoint name '{}' in SIPP_AUTH_ENDPOINTS", name); |
|
| 361 | + | } |
|
| 362 | + | } |
|
| 363 | + | ||
| 364 | + | if !server_config.auth_endpoints.is_empty() && server_config.api_key.is_none() { |
|
| 365 | + | eprintln!("Warning: SIPP_AUTH_ENDPOINTS is set but SIPP_API_KEY is not configured"); |
|
| 366 | + | } |
|
| 367 | + | ||
| 368 | + | if server_config.auth_endpoints.is_empty() { |
|
| 369 | + | println!("Auth: disabled (no endpoints require authentication)"); |
|
| 370 | + | } else { |
|
| 371 | + | let names: Vec<&str> = server_config.auth_endpoints.iter().map(|s| s.as_str()).collect(); |
|
| 372 | + | println!("Auth: enabled for endpoints: {}", names.join(", ")); |
|
| 373 | + | } |
|
| 374 | + | ||
| 375 | + | println!("Max content size: {} bytes", server_config.max_content_size); |
|
| 376 | + | ||
| 377 | + | let state = AppState { |
|
| 378 | + | db: db::init_db().expect("Failed to initialize database"), |
|
| 379 | + | highlighter: Arc::new(Highlighter::new()), |
|
| 380 | + | server_config, |
|
| 381 | + | }; |
|
| 382 | + | ||
| 383 | + | let api_routes = build_api_routes(&state); |
|
| 384 | + | ||
| 385 | + | let app = Router::new() |
|
| 386 | + | .route("/", get(index)) |
|
| 387 | + | .route("/admin", get(admin)) |
|
| 388 | + | .route("/s/{short_id}", get(view_snippet)) |
|
| 389 | + | .route("/snippets", post(create_snippet)) |
|
| 390 | + | .merge(api_routes) |
|
| 391 | + | .route("/assets/{*path}", get(serve_assets)) |
|
| 392 | + | .route("/static/{*path}", get(serve_static)) |
|
| 393 | + | .with_state(state); |
|
| 394 | + | ||
| 395 | + | let addr = format!("{}:{}", host, port); |
|
| 396 | + | let listener = tokio::net::TcpListener::bind(&addr) |
|
| 397 | + | .await |
|
| 398 | + | .unwrap_or_else(|_| panic!("Failed to bind to {}", addr)); |
|
| 399 | + | ||
| 400 | + | println!("Server running at http://{}:{}", host, port); |
|
| 401 | + | ||
| 402 | + | axum::serve(listener, app) |
|
| 403 | + | .await |
|
| 404 | + | .expect("Failed to start server"); |
|
| 405 | + | } |
| 1 | + | use arboard::Clipboard; |
|
| 2 | + | use crossterm::event::{self, Event, KeyCode, KeyModifiers}; |
|
| 3 | + | use ratatui::{ |
|
| 4 | + | DefaultTerminal, |
|
| 5 | + | layout::{Alignment, Constraint, Layout}, |
|
| 6 | + | style::{Color, Modifier, Style}, |
|
| 7 | + | text::{Line, Span, Text}, |
|
| 8 | + | widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Widget, Wrap}, |
|
| 9 | + | }; |
|
| 10 | + | use crate::backend::Backend; |
|
| 11 | + | use crate::config; |
|
| 12 | + | use crate::db::Snippet; |
|
| 13 | + | use std::io::Cursor; |
|
| 14 | + | use std::path::PathBuf; |
|
| 15 | + | use std::time::{Duration, Instant}; |
|
| 16 | + | use syntect::easy::HighlightLines; |
|
| 17 | + | use syntect::highlighting::Theme; |
|
| 18 | + | use syntect::parsing::SyntaxSet; |
|
| 19 | + | use syntect::util::LinesWithEndings; |
|
| 20 | + | ||
| 21 | + | enum Focus { |
|
| 22 | + | List, |
|
| 23 | + | Content, |
|
| 24 | + | CreateName, |
|
| 25 | + | CreateContent, |
|
| 26 | + | EditName, |
|
| 27 | + | EditContent, |
|
| 28 | + | Search, |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | struct App { |
|
| 32 | + | snippets: Vec<Snippet>, |
|
| 33 | + | list_state: ListState, |
|
| 34 | + | should_quit: bool, |
|
| 35 | + | status_message: Option<(String, Instant)>, |
|
| 36 | + | focus: Focus, |
|
| 37 | + | content_scroll: u16, |
|
| 38 | + | show_help: bool, |
|
| 39 | + | confirm_delete: bool, |
|
| 40 | + | syntax_set: SyntaxSet, |
|
| 41 | + | theme: Theme, |
|
| 42 | + | create_name: String, |
|
| 43 | + | create_content: String, |
|
| 44 | + | edit_short_id: Option<String>, |
|
| 45 | + | search_query: String, |
|
| 46 | + | filtered_indices: Option<Vec<usize>>, |
|
| 47 | + | is_remote: bool, |
|
| 48 | + | remote_url: Option<String>, |
|
| 49 | + | wrap_content: bool, |
|
| 50 | + | edit_scroll: u16, |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | impl App { |
|
| 54 | + | fn new(snippets: Vec<Snippet>, is_remote: bool, remote_url: Option<String>) -> Self { |
|
| 55 | + | let mut list_state = ListState::default(); |
|
| 56 | + | if !snippets.is_empty() { |
|
| 57 | + | list_state.select(Some(0)); |
|
| 58 | + | } |
|
| 59 | + | let syntax_set = SyntaxSet::load_defaults_newlines(); |
|
| 60 | + | let theme_data = include_bytes!("ansi.tmTheme"); |
|
| 61 | + | let theme = |
|
| 62 | + | syntect::highlighting::ThemeSet::load_from_reader(&mut Cursor::new(&theme_data[..])) |
|
| 63 | + | .expect("failed to load base16 theme"); |
|
| 64 | + | Self { |
|
| 65 | + | snippets, |
|
| 66 | + | list_state, |
|
| 67 | + | should_quit: false, |
|
| 68 | + | status_message: None, |
|
| 69 | + | focus: Focus::List, |
|
| 70 | + | content_scroll: 0, |
|
| 71 | + | show_help: false, |
|
| 72 | + | confirm_delete: false, |
|
| 73 | + | syntax_set, |
|
| 74 | + | theme, |
|
| 75 | + | create_name: String::new(), |
|
| 76 | + | create_content: String::new(), |
|
| 77 | + | edit_short_id: None, |
|
| 78 | + | search_query: String::new(), |
|
| 79 | + | filtered_indices: None, |
|
| 80 | + | is_remote, |
|
| 81 | + | remote_url, |
|
| 82 | + | wrap_content: true, |
|
| 83 | + | edit_scroll: 0, |
|
| 84 | + | } |
|
| 85 | + | } |
|
| 86 | + | ||
| 87 | + | fn selected_snippet(&self) -> Option<&Snippet> { |
|
| 88 | + | self.list_state.selected().and_then(|i| { |
|
| 89 | + | if let Some(indices) = &self.filtered_indices { |
|
| 90 | + | indices.get(i).and_then(|&real| self.snippets.get(real)) |
|
| 91 | + | } else { |
|
| 92 | + | self.snippets.get(i) |
|
| 93 | + | } |
|
| 94 | + | }) |
|
| 95 | + | } |
|
| 96 | + | ||
| 97 | + | fn visible_count(&self) -> usize { |
|
| 98 | + | match &self.filtered_indices { |
|
| 99 | + | Some(indices) => indices.len(), |
|
| 100 | + | None => self.snippets.len(), |
|
| 101 | + | } |
|
| 102 | + | } |
|
| 103 | + | ||
| 104 | + | fn move_up(&mut self) { |
|
| 105 | + | let count = self.visible_count(); |
|
| 106 | + | if count == 0 { |
|
| 107 | + | return; |
|
| 108 | + | } |
|
| 109 | + | let i = match self.list_state.selected() { |
|
| 110 | + | Some(i) if i > 0 => i - 1, |
|
| 111 | + | Some(_) => count - 1, |
|
| 112 | + | None => 0, |
|
| 113 | + | }; |
|
| 114 | + | self.list_state.select(Some(i)); |
|
| 115 | + | self.content_scroll = 0; |
|
| 116 | + | } |
|
| 117 | + | ||
| 118 | + | fn move_down(&mut self) { |
|
| 119 | + | let count = self.visible_count(); |
|
| 120 | + | if count == 0 { |
|
| 121 | + | return; |
|
| 122 | + | } |
|
| 123 | + | let i = match self.list_state.selected() { |
|
| 124 | + | Some(i) if i < count - 1 => i + 1, |
|
| 125 | + | Some(_) => 0, |
|
| 126 | + | None => 0, |
|
| 127 | + | }; |
|
| 128 | + | self.list_state.select(Some(i)); |
|
| 129 | + | self.content_scroll = 0; |
|
| 130 | + | } |
|
| 131 | + | ||
| 132 | + | fn scroll_up(&mut self) { |
|
| 133 | + | self.content_scroll = self.content_scroll.saturating_sub(1); |
|
| 134 | + | } |
|
| 135 | + | ||
| 136 | + | fn scroll_down(&mut self, max_lines: u16) { |
|
| 137 | + | if self.content_scroll < max_lines { |
|
| 138 | + | self.content_scroll += 1; |
|
| 139 | + | } |
|
| 140 | + | } |
|
| 141 | + | ||
| 142 | + | fn copy_selected(&mut self) { |
|
| 143 | + | if let Some(snippet) = self.selected_snippet() { |
|
| 144 | + | if let Ok(mut clipboard) = Clipboard::new() { |
|
| 145 | + | let _ = clipboard.set_text(&snippet.content); |
|
| 146 | + | self.status_message = Some(("Copied!".to_string(), Instant::now())); |
|
| 147 | + | } |
|
| 148 | + | } |
|
| 149 | + | } |
|
| 150 | + | ||
| 151 | + | fn copy_link(&mut self) { |
|
| 152 | + | match &self.remote_url { |
|
| 153 | + | Some(url) => { |
|
| 154 | + | if let Some(snippet) = self.selected_snippet() { |
|
| 155 | + | let link = format!("{}/s/{}", url.trim_end_matches('/'), snippet.short_id); |
|
| 156 | + | if let Ok(mut clipboard) = Clipboard::new() { |
|
| 157 | + | let _ = clipboard.set_text(&link); |
|
| 158 | + | self.status_message = |
|
| 159 | + | Some(("Link copied!".to_string(), Instant::now())); |
|
| 160 | + | } |
|
| 161 | + | } |
|
| 162 | + | } |
|
| 163 | + | None => { |
|
| 164 | + | self.status_message = |
|
| 165 | + | Some(("No remote URL configured".to_string(), Instant::now())); |
|
| 166 | + | } |
|
| 167 | + | } |
|
| 168 | + | } |
|
| 169 | + | ||
| 170 | + | fn open_in_browser(&mut self) { |
|
| 171 | + | match &self.remote_url { |
|
| 172 | + | Some(url) => { |
|
| 173 | + | if let Some(snippet) = self.selected_snippet() { |
|
| 174 | + | let link = format!("{}/s/{}", url.trim_end_matches('/'), snippet.short_id); |
|
| 175 | + | if let Err(e) = open::that(&link) { |
|
| 176 | + | self.status_message = |
|
| 177 | + | Some((format!("Failed to open browser: {}", e), Instant::now())); |
|
| 178 | + | } else { |
|
| 179 | + | self.status_message = |
|
| 180 | + | Some(("Opened in browser!".to_string(), Instant::now())); |
|
| 181 | + | } |
|
| 182 | + | } |
|
| 183 | + | } |
|
| 184 | + | None => { |
|
| 185 | + | self.status_message = |
|
| 186 | + | Some(("No remote URL configured".to_string(), Instant::now())); |
|
| 187 | + | } |
|
| 188 | + | } |
|
| 189 | + | } |
|
| 190 | + | ||
| 191 | + | fn delete_selected(&mut self, backend: &Backend) { |
|
| 192 | + | if let Some(selected_index) = self.list_state.selected() { |
|
| 193 | + | let real_index = if let Some(indices) = &self.filtered_indices { |
|
| 194 | + | match indices.get(selected_index) { |
|
| 195 | + | Some(&ri) => ri, |
|
| 196 | + | None => return, |
|
| 197 | + | } |
|
| 198 | + | } else { |
|
| 199 | + | selected_index |
|
| 200 | + | }; |
|
| 201 | + | if let Some(snippet) = self.snippets.get(real_index) { |
|
| 202 | + | let short_id = snippet.short_id.clone(); |
|
| 203 | + | match backend.delete_snippet(&short_id) { |
|
| 204 | + | Ok(true) => { |
|
| 205 | + | self.snippets.remove(real_index); |
|
| 206 | + | if self.filtered_indices.is_some() { |
|
| 207 | + | self.update_search_filter(); |
|
| 208 | + | } |
|
| 209 | + | let count = self.visible_count(); |
|
| 210 | + | if count == 0 { |
|
| 211 | + | self.list_state.select(None); |
|
| 212 | + | } else if selected_index >= count { |
|
| 213 | + | self.list_state.select(Some(count - 1)); |
|
| 214 | + | } else { |
|
| 215 | + | self.list_state.select(Some(selected_index)); |
|
| 216 | + | } |
|
| 217 | + | self.status_message = Some(("Deleted!".to_string(), Instant::now())); |
|
| 218 | + | } |
|
| 219 | + | Ok(false) => { |
|
| 220 | + | self.status_message = |
|
| 221 | + | Some(("Snippet not found".to_string(), Instant::now())); |
|
| 222 | + | } |
|
| 223 | + | Err(e) => { |
|
| 224 | + | self.status_message = Some((e.to_string(), Instant::now())); |
|
| 225 | + | } |
|
| 226 | + | } |
|
| 227 | + | } |
|
| 228 | + | } |
|
| 229 | + | } |
|
| 230 | + | ||
| 231 | + | fn refresh(&mut self, backend: &Backend) { |
|
| 232 | + | match backend.list_snippets() { |
|
| 233 | + | Ok(snippets) => { |
|
| 234 | + | self.snippets = snippets; |
|
| 235 | + | self.filtered_indices = None; |
|
| 236 | + | self.search_query.clear(); |
|
| 237 | + | if self.snippets.is_empty() { |
|
| 238 | + | self.list_state.select(None); |
|
| 239 | + | } else { |
|
| 240 | + | let idx = self.list_state.selected().unwrap_or(0); |
|
| 241 | + | if idx >= self.snippets.len() { |
|
| 242 | + | self.list_state.select(Some(self.snippets.len() - 1)); |
|
| 243 | + | } |
|
| 244 | + | } |
|
| 245 | + | self.status_message = Some(("Refreshed!".to_string(), Instant::now())); |
|
| 246 | + | } |
|
| 247 | + | Err(e) => { |
|
| 248 | + | self.status_message = Some((e.to_string(), Instant::now())); |
|
| 249 | + | } |
|
| 250 | + | } |
|
| 251 | + | } |
|
| 252 | + | ||
| 253 | + | fn cursor_position_wrapped(&self, width: u16) -> (u16, u16) { |
|
| 254 | + | let w = width as usize; |
|
| 255 | + | if w == 0 { |
|
| 256 | + | return (0, 0); |
|
| 257 | + | } |
|
| 258 | + | let text = &self.create_content; |
|
| 259 | + | let mut visual_row: usize = 0; |
|
| 260 | + | let lines: Vec<&str> = if text.is_empty() { |
|
| 261 | + | vec![""] |
|
| 262 | + | } else if text.ends_with('\n') { |
|
| 263 | + | text.split('\n').collect() |
|
| 264 | + | } else { |
|
| 265 | + | text.split('\n').collect() |
|
| 266 | + | }; |
|
| 267 | + | let last_idx = lines.len() - 1; |
|
| 268 | + | for (i, line) in lines.iter().enumerate() { |
|
| 269 | + | let line_len = line.len(); |
|
| 270 | + | let wrapped_lines = if line_len == 0 { |
|
| 271 | + | 1 |
|
| 272 | + | } else { |
|
| 273 | + | (line_len + w - 1) / w |
|
| 274 | + | }; |
|
| 275 | + | if i < last_idx { |
|
| 276 | + | visual_row += wrapped_lines; |
|
| 277 | + | } else { |
|
| 278 | + | // cursor is at end of this last line |
|
| 279 | + | let cursor_col = if text.ends_with('\n') { 0 } else { line_len }; |
|
| 280 | + | let extra_rows = cursor_col / w; |
|
| 281 | + | let col = cursor_col % w; |
|
| 282 | + | visual_row += extra_rows; |
|
| 283 | + | return (col as u16, visual_row as u16); |
|
| 284 | + | } |
|
| 285 | + | } |
|
| 286 | + | (0, visual_row as u16) |
|
| 287 | + | } |
|
| 288 | + | ||
| 289 | + | fn auto_scroll_edit(&mut self, cursor_visual_row: u16, visible_height: u16) { |
|
| 290 | + | if visible_height == 0 { |
|
| 291 | + | return; |
|
| 292 | + | } |
|
| 293 | + | if cursor_visual_row < self.edit_scroll { |
|
| 294 | + | self.edit_scroll = cursor_visual_row; |
|
| 295 | + | } else if cursor_visual_row >= self.edit_scroll + visible_height { |
|
| 296 | + | self.edit_scroll = cursor_visual_row - visible_height + 1; |
|
| 297 | + | } |
|
| 298 | + | } |
|
| 299 | + | ||
| 300 | + | fn start_create(&mut self) { |
|
| 301 | + | self.create_name.clear(); |
|
| 302 | + | self.create_content.clear(); |
|
| 303 | + | self.edit_scroll = 0; |
|
| 304 | + | self.focus = Focus::CreateName; |
|
| 305 | + | } |
|
| 306 | + | ||
| 307 | + | fn save_create(&mut self, backend: &Backend) { |
|
| 308 | + | if self.create_name.trim().is_empty() { |
|
| 309 | + | self.status_message = Some(("Name cannot be empty".to_string(), Instant::now())); |
|
| 310 | + | return; |
|
| 311 | + | } |
|
| 312 | + | match backend.create_snippet(&self.create_name, &self.create_content) { |
|
| 313 | + | Ok(snippet) => { |
|
| 314 | + | self.snippets.insert(0, snippet); |
|
| 315 | + | self.list_state.select(Some(0)); |
|
| 316 | + | self.filtered_indices = None; |
|
| 317 | + | self.search_query.clear(); |
|
| 318 | + | self.status_message = Some(("Created!".to_string(), Instant::now())); |
|
| 319 | + | self.focus = Focus::List; |
|
| 320 | + | self.create_name.clear(); |
|
| 321 | + | self.create_content.clear(); |
|
| 322 | + | } |
|
| 323 | + | Err(e) => { |
|
| 324 | + | self.status_message = Some((e.to_string(), Instant::now())); |
|
| 325 | + | } |
|
| 326 | + | } |
|
| 327 | + | } |
|
| 328 | + | ||
| 329 | + | fn cancel_create(&mut self) { |
|
| 330 | + | self.create_name.clear(); |
|
| 331 | + | self.create_content.clear(); |
|
| 332 | + | self.focus = Focus::List; |
|
| 333 | + | } |
|
| 334 | + | ||
| 335 | + | fn start_edit(&mut self) { |
|
| 336 | + | let data = self.selected_snippet().map(|s| { |
|
| 337 | + | (s.name.clone(), s.content.clone(), s.short_id.clone()) |
|
| 338 | + | }); |
|
| 339 | + | if let Some((name, content, short_id)) = data { |
|
| 340 | + | self.create_name = name; |
|
| 341 | + | self.create_content = content; |
|
| 342 | + | self.edit_short_id = Some(short_id); |
|
| 343 | + | self.edit_scroll = 0; |
|
| 344 | + | self.focus = Focus::EditName; |
|
| 345 | + | } |
|
| 346 | + | } |
|
| 347 | + | ||
| 348 | + | fn save_edit(&mut self, backend: &Backend) { |
|
| 349 | + | if self.create_name.trim().is_empty() { |
|
| 350 | + | self.status_message = Some(("Name cannot be empty".to_string(), Instant::now())); |
|
| 351 | + | return; |
|
| 352 | + | } |
|
| 353 | + | let short_id = match &self.edit_short_id { |
|
| 354 | + | Some(id) => id.clone(), |
|
| 355 | + | None => return, |
|
| 356 | + | }; |
|
| 357 | + | match backend.update_snippet(&short_id, &self.create_name, &self.create_content) { |
|
| 358 | + | Ok(Some(updated)) => { |
|
| 359 | + | if let Some(pos) = self.snippets.iter().position(|s| s.short_id == short_id) { |
|
| 360 | + | self.snippets[pos] = updated; |
|
| 361 | + | } |
|
| 362 | + | self.status_message = Some(("Updated!".to_string(), Instant::now())); |
|
| 363 | + | self.focus = Focus::List; |
|
| 364 | + | self.create_name.clear(); |
|
| 365 | + | self.create_content.clear(); |
|
| 366 | + | self.edit_short_id = None; |
|
| 367 | + | } |
|
| 368 | + | Ok(None) => { |
|
| 369 | + | self.status_message = Some(("Snippet not found".to_string(), Instant::now())); |
|
| 370 | + | } |
|
| 371 | + | Err(e) => { |
|
| 372 | + | self.status_message = Some((e.to_string(), Instant::now())); |
|
| 373 | + | } |
|
| 374 | + | } |
|
| 375 | + | } |
|
| 376 | + | ||
| 377 | + | fn cancel_edit(&mut self) { |
|
| 378 | + | self.create_name.clear(); |
|
| 379 | + | self.create_content.clear(); |
|
| 380 | + | self.edit_short_id = None; |
|
| 381 | + | self.focus = Focus::List; |
|
| 382 | + | } |
|
| 383 | + | ||
| 384 | + | fn start_search(&mut self) { |
|
| 385 | + | self.search_query.clear(); |
|
| 386 | + | self.filtered_indices = Some((0..self.snippets.len()).collect()); |
|
| 387 | + | self.focus = Focus::Search; |
|
| 388 | + | self.list_state.select(if self.snippets.is_empty() { None } else { Some(0) }); |
|
| 389 | + | } |
|
| 390 | + | ||
| 391 | + | fn update_search_filter(&mut self) { |
|
| 392 | + | let query = self.search_query.to_lowercase(); |
|
| 393 | + | let indices: Vec<usize> = self |
|
| 394 | + | .snippets |
|
| 395 | + | .iter() |
|
| 396 | + | .enumerate() |
|
| 397 | + | .filter(|(_, s)| s.name.to_lowercase().contains(&query)) |
|
| 398 | + | .map(|(i, _)| i) |
|
| 399 | + | .collect(); |
|
| 400 | + | self.filtered_indices = Some(indices); |
|
| 401 | + | if self.visible_count() == 0 { |
|
| 402 | + | self.list_state.select(None); |
|
| 403 | + | } else { |
|
| 404 | + | self.list_state.select(Some(0)); |
|
| 405 | + | } |
|
| 406 | + | } |
|
| 407 | + | ||
| 408 | + | fn cancel_search(&mut self) { |
|
| 409 | + | self.filtered_indices = None; |
|
| 410 | + | self.search_query.clear(); |
|
| 411 | + | self.focus = Focus::List; |
|
| 412 | + | } |
|
| 413 | + | ||
| 414 | + | fn confirm_search(&mut self) { |
|
| 415 | + | let real_index = self.list_state.selected().and_then(|i| { |
|
| 416 | + | self.filtered_indices.as_ref().and_then(|indices| indices.get(i).copied()) |
|
| 417 | + | }); |
|
| 418 | + | self.filtered_indices = None; |
|
| 419 | + | self.search_query.clear(); |
|
| 420 | + | self.focus = Focus::List; |
|
| 421 | + | if let Some(ri) = real_index { |
|
| 422 | + | self.list_state.select(Some(ri)); |
|
| 423 | + | } |
|
| 424 | + | } |
|
| 425 | + | ||
| 426 | + | fn clear_expired_status(&mut self) { |
|
| 427 | + | if let Some((_, time)) = &self.status_message { |
|
| 428 | + | if time.elapsed() > Duration::from_secs(2) { |
|
| 429 | + | self.status_message = None; |
|
| 430 | + | } |
|
| 431 | + | } |
|
| 432 | + | } |
|
| 433 | + | ||
| 434 | + | fn highlight_content(&self, name: &str, content: &str) -> Text<'static> { |
|
| 435 | + | let raw_ext = name.rsplit('.').next().unwrap_or(""); |
|
| 436 | + | let ext = match raw_ext { |
|
| 437 | + | "ts" | "tsx" | "jsx" => "js", |
|
| 438 | + | other => other, |
|
| 439 | + | }; |
|
| 440 | + | let syntax = self |
|
| 441 | + | .syntax_set |
|
| 442 | + | .find_syntax_by_extension(ext) |
|
| 443 | + | .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text()); |
|
| 444 | + | let mut highlighter = HighlightLines::new(syntax, &self.theme); |
|
| 445 | + | ||
| 446 | + | let lines: Vec<Line<'static>> = LinesWithEndings::from(content) |
|
| 447 | + | .map(|line| { |
|
| 448 | + | let ranges = highlighter |
|
| 449 | + | .highlight_line(line, &self.syntax_set) |
|
| 450 | + | .unwrap_or_default(); |
|
| 451 | + | let spans: Vec<Span<'static>> = ranges |
|
| 452 | + | .into_iter() |
|
| 453 | + | .map(|(style, text)| { |
|
| 454 | + | let color = to_ratatui_color(style.foreground); |
|
| 455 | + | Span::styled(text.to_owned(), Style::default().fg(color)) |
|
| 456 | + | }) |
|
| 457 | + | .collect(); |
|
| 458 | + | Line::from(spans) |
|
| 459 | + | }) |
|
| 460 | + | .collect(); |
|
| 461 | + | ||
| 462 | + | Text::from(lines) |
|
| 463 | + | } |
|
| 464 | + | } |
|
| 465 | + | ||
| 466 | + | fn to_ratatui_color(color: syntect::highlighting::Color) -> Color { |
|
| 467 | + | if color.a == 0 { |
|
| 468 | + | Color::Indexed(color.r) |
|
| 469 | + | } else { |
|
| 470 | + | Color::Reset |
|
| 471 | + | } |
|
| 472 | + | } |
|
| 473 | + | ||
| 474 | + | fn resolve_backend(remote: Option<String>, api_key: Option<String>) -> Result<(Backend, bool, Option<String>), Box<dyn std::error::Error>> { |
|
| 475 | + | if let Some(url) = remote { |
|
| 476 | + | return Ok(( |
|
| 477 | + | Backend::remote(url.clone(), api_key), |
|
| 478 | + | true, |
|
| 479 | + | Some(url), |
|
| 480 | + | )); |
|
| 481 | + | } |
|
| 482 | + | ||
| 483 | + | if !std::path::Path::new(&crate::db::db_path()).exists() { |
|
| 484 | + | let cfg = config::load_config(); |
|
| 485 | + | let url = cfg.remote_url.unwrap_or_else(|| "http://localhost:3000".to_string()); |
|
| 486 | + | let api_key = api_key.or(cfg.api_key); |
|
| 487 | + | return Ok((Backend::remote(url.clone(), api_key), true, Some(url))); |
|
| 488 | + | } |
|
| 489 | + | ||
| 490 | + | Ok((Backend::local()?, false, Some("http://localhost:3000".to_string()))) |
|
| 491 | + | } |
|
| 492 | + | ||
| 493 | + | pub fn run_auth() -> Result<(), Box<dyn std::error::Error>> { |
|
| 494 | + | use std::io::{self, Write}; |
|
| 495 | + | ||
| 496 | + | print!("Remote URL: "); |
|
| 497 | + | io::stdout().flush()?; |
|
| 498 | + | let mut remote_url = String::new(); |
|
| 499 | + | io::stdin().read_line(&mut remote_url)?; |
|
| 500 | + | let remote_url = remote_url.trim().to_string(); |
|
| 501 | + | ||
| 502 | + | print!("API Key: "); |
|
| 503 | + | io::stdout().flush()?; |
|
| 504 | + | let api_key = rpassword::read_password()?; |
|
| 505 | + | let api_key = api_key.trim().to_string(); |
|
| 506 | + | ||
| 507 | + | let cfg = config::Config { |
|
| 508 | + | remote_url: if remote_url.is_empty() { |
|
| 509 | + | None |
|
| 510 | + | } else { |
|
| 511 | + | Some(remote_url) |
|
| 512 | + | }, |
|
| 513 | + | api_key: if api_key.is_empty() { |
|
| 514 | + | None |
|
| 515 | + | } else { |
|
| 516 | + | Some(api_key) |
|
| 517 | + | }, |
|
| 518 | + | }; |
|
| 519 | + | ||
| 520 | + | config::save_config(&cfg)?; |
|
| 521 | + | println!("Config saved to {}", config::config_path().display()); |
|
| 522 | + | Ok(()) |
|
| 523 | + | } |
|
| 524 | + | ||
| 525 | + | pub fn run_interactive(remote: Option<String>, api_key: Option<String>) -> Result<(), Box<dyn std::error::Error>> { |
|
| 526 | + | let (backend, is_remote, remote_url) = resolve_backend(remote, api_key)?; |
|
| 527 | + | ||
| 528 | + | let snippets = match backend.list_snippets() { |
|
| 529 | + | Ok(s) => s, |
|
| 530 | + | Err(e) => { |
|
| 531 | + | eprintln!("Failed to load snippets: {}", e); |
|
| 532 | + | Vec::new() |
|
| 533 | + | } |
|
| 534 | + | }; |
|
| 535 | + | ||
| 536 | + | ratatui::run(|terminal| run_app(terminal, App::new(snippets, is_remote, remote_url), &backend)) |
|
| 537 | + | } |
|
| 538 | + | ||
| 539 | + | pub fn run_file_upload(remote: Option<String>, api_key: Option<String>, file: PathBuf) -> Result<(), Box<dyn std::error::Error>> { |
|
| 540 | + | let (backend, _, remote_url) = resolve_backend(remote, api_key)?; |
|
| 541 | + | ||
| 542 | + | let name = file |
|
| 543 | + | .file_name() |
|
| 544 | + | .ok_or("Invalid file path")? |
|
| 545 | + | .to_string_lossy() |
|
| 546 | + | .to_string(); |
|
| 547 | + | let content = std::fs::read_to_string(&file) |
|
| 548 | + | .map_err(|e| format!("Failed to read file: {}", e))?; |
|
| 549 | + | let snippet = backend |
|
| 550 | + | .create_snippet(&name, &content) |
|
| 551 | + | .map_err(|e| format!("{}", e))?; |
|
| 552 | + | let link = match &remote_url { |
|
| 553 | + | Some(url) => format!("{}/s/{}", url.trim_end_matches('/'), snippet.short_id), |
|
| 554 | + | None => snippet.short_id.clone(), |
|
| 555 | + | }; |
|
| 556 | + | println!("{}", link); |
|
| 557 | + | if let Ok(mut clipboard) = Clipboard::new() { |
|
| 558 | + | let _ = clipboard.set_text(&link); |
|
| 559 | + | println!("\u{2714} Copied to clipboard!"); |
|
| 560 | + | } |
|
| 561 | + | Ok(()) |
|
| 562 | + | } |
|
| 563 | + | ||
| 564 | + | fn run_app( |
|
| 565 | + | terminal: &mut DefaultTerminal, |
|
| 566 | + | mut app: App, |
|
| 567 | + | backend: &Backend, |
|
| 568 | + | ) -> Result<(), Box<dyn std::error::Error>> { |
|
| 569 | + | while !app.should_quit { |
|
| 570 | + | app.clear_expired_status(); |
|
| 571 | + | ||
| 572 | + | let content_line_count = app |
|
| 573 | + | .selected_snippet() |
|
| 574 | + | .map(|s| s.content.lines().count() as u16) |
|
| 575 | + | .unwrap_or(0); |
|
| 576 | + | ||
| 577 | + | terminal.draw(|frame| { |
|
| 578 | + | let outer = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]) |
|
| 579 | + | .split(frame.area()); |
|
| 580 | + | ||
| 581 | + | let chunks = Layout::horizontal([ |
|
| 582 | + | Constraint::Percentage(30), |
|
| 583 | + | Constraint::Percentage(70), |
|
| 584 | + | ]) |
|
| 585 | + | .split(outer[0]); |
|
| 586 | + | ||
| 587 | + | let items: Vec<ListItem> = if let Some(indices) = &app.filtered_indices { |
|
| 588 | + | indices |
|
| 589 | + | .iter() |
|
| 590 | + | .filter_map(|&i| app.snippets.get(i)) |
|
| 591 | + | .map(|s| ListItem::new(s.name.as_str())) |
|
| 592 | + | .collect() |
|
| 593 | + | } else { |
|
| 594 | + | app.snippets |
|
| 595 | + | .iter() |
|
| 596 | + | .map(|s| ListItem::new(s.name.as_str())) |
|
| 597 | + | .collect() |
|
| 598 | + | }; |
|
| 599 | + | ||
| 600 | + | let list_border_style = match app.focus { |
|
| 601 | + | Focus::List | Focus::Search => Style::default().fg(Color::Yellow), |
|
| 602 | + | _ => Style::default().fg(Color::DarkGray), |
|
| 603 | + | }; |
|
| 604 | + | let content_border_style = match app.focus { |
|
| 605 | + | Focus::Content => Style::default().fg(Color::Yellow), |
|
| 606 | + | _ => Style::default().fg(Color::DarkGray), |
|
| 607 | + | }; |
|
| 608 | + | ||
| 609 | + | let list = List::new(items) |
|
| 610 | + | .block( |
|
| 611 | + | Block::default() |
|
| 612 | + | .title(" Snippets ") |
|
| 613 | + | .borders(Borders::ALL) |
|
| 614 | + | .border_style(list_border_style), |
|
| 615 | + | ) |
|
| 616 | + | .highlight_style( |
|
| 617 | + | Style::default() |
|
| 618 | + | .fg(Color::Yellow) |
|
| 619 | + | .add_modifier(Modifier::BOLD), |
|
| 620 | + | ) |
|
| 621 | + | .highlight_symbol("▶ "); |
|
| 622 | + | ||
| 623 | + | if matches!(app.focus, Focus::Search) { |
|
| 624 | + | let search_split = Layout::vertical([ |
|
| 625 | + | Constraint::Min(1), |
|
| 626 | + | Constraint::Length(3), |
|
| 627 | + | ]) |
|
| 628 | + | .split(chunks[0]); |
|
| 629 | + | ||
| 630 | + | let search_items: Vec<ListItem> = if let Some(indices) = &app.filtered_indices { |
|
| 631 | + | indices |
|
| 632 | + | .iter() |
|
| 633 | + | .filter_map(|&i| app.snippets.get(i)) |
|
| 634 | + | .map(|s| ListItem::new(s.name.as_str())) |
|
| 635 | + | .collect() |
|
| 636 | + | } else { |
|
| 637 | + | app.snippets.iter().map(|s| ListItem::new(s.name.as_str())).collect() |
|
| 638 | + | }; |
|
| 639 | + | let search_list = List::new(search_items) |
|
| 640 | + | .block( |
|
| 641 | + | Block::default() |
|
| 642 | + | .title(" Snippets ") |
|
| 643 | + | .borders(Borders::ALL) |
|
| 644 | + | .border_style(list_border_style), |
|
| 645 | + | ) |
|
| 646 | + | .highlight_style( |
|
| 647 | + | Style::default() |
|
| 648 | + | .fg(Color::Yellow) |
|
| 649 | + | .add_modifier(Modifier::BOLD), |
|
| 650 | + | ) |
|
| 651 | + | .highlight_symbol("▶ "); |
|
| 652 | + | frame.render_stateful_widget(search_list, search_split[0], &mut app.list_state); |
|
| 653 | + | ||
| 654 | + | let search_input = Paragraph::new(app.search_query.as_str()).block( |
|
| 655 | + | Block::default() |
|
| 656 | + | .title(" Search ") |
|
| 657 | + | .borders(Borders::ALL) |
|
| 658 | + | .border_style(Style::default().fg(Color::Yellow)), |
|
| 659 | + | ); |
|
| 660 | + | frame.render_widget(search_input, search_split[1]); |
|
| 661 | + | ||
| 662 | + | let x = search_split[1].x + 1 + app.search_query.len() as u16; |
|
| 663 | + | let y = search_split[1].y + 1; |
|
| 664 | + | frame.set_cursor_position((x, y)); |
|
| 665 | + | } else { |
|
| 666 | + | frame.render_stateful_widget(list, chunks[0], &mut app.list_state); |
|
| 667 | + | } |
|
| 668 | + | ||
| 669 | + | match app.focus { |
|
| 670 | + | Focus::CreateName | Focus::CreateContent | Focus::EditName | Focus::EditContent => { |
|
| 671 | + | let form_title = match app.focus { |
|
| 672 | + | Focus::EditName | Focus::EditContent => " Edit Snippet ", |
|
| 673 | + | _ => " New Snippet ", |
|
| 674 | + | }; |
|
| 675 | + | let create_block = Block::default() |
|
| 676 | + | .title(form_title) |
|
| 677 | + | .borders(Borders::ALL) |
|
| 678 | + | .border_style(Style::default().fg(Color::Yellow)); |
|
| 679 | + | ||
| 680 | + | let inner = create_block.inner(chunks[1]); |
|
| 681 | + | frame.render_widget(create_block, chunks[1]); |
|
| 682 | + | ||
| 683 | + | let form_layout = Layout::vertical([ |
|
| 684 | + | Constraint::Length(3), |
|
| 685 | + | Constraint::Min(1), |
|
| 686 | + | ]) |
|
| 687 | + | .split(inner); |
|
| 688 | + | ||
| 689 | + | let name_style = match app.focus { |
|
| 690 | + | Focus::CreateName | Focus::EditName => Style::default().fg(Color::Yellow), |
|
| 691 | + | _ => Style::default().fg(Color::DarkGray), |
|
| 692 | + | }; |
|
| 693 | + | let name_input = Paragraph::new(app.create_name.as_str()).block( |
|
| 694 | + | Block::default() |
|
| 695 | + | .title(" Name ") |
|
| 696 | + | .borders(Borders::ALL) |
|
| 697 | + | .border_style(name_style), |
|
| 698 | + | ); |
|
| 699 | + | frame.render_widget(name_input, form_layout[0]); |
|
| 700 | + | ||
| 701 | + | let content_style = match app.focus { |
|
| 702 | + | Focus::CreateContent | Focus::EditContent => Style::default().fg(Color::Yellow), |
|
| 703 | + | _ => Style::default().fg(Color::DarkGray), |
|
| 704 | + | }; |
|
| 705 | + | let mut content_input = Paragraph::new(app.create_content.as_str()).block( |
|
| 706 | + | Block::default() |
|
| 707 | + | .title(" Content ") |
|
| 708 | + | .borders(Borders::ALL) |
|
| 709 | + | .border_style(content_style), |
|
| 710 | + | ); |
|
| 711 | + | if app.wrap_content { |
|
| 712 | + | content_input = content_input.wrap(Wrap { trim: false }); |
|
| 713 | + | } |
|
| 714 | + | content_input = content_input.scroll((app.edit_scroll, 0)); |
|
| 715 | + | frame.render_widget(content_input, form_layout[1]); |
|
| 716 | + | ||
| 717 | + | let content_inner = Block::default() |
|
| 718 | + | .borders(Borders::ALL) |
|
| 719 | + | .inner(form_layout[1]); |
|
| 720 | + | let inner_width = content_inner.width; |
|
| 721 | + | let inner_height = content_inner.height; |
|
| 722 | + | ||
| 723 | + | match app.focus { |
|
| 724 | + | Focus::CreateName | Focus::EditName => { |
|
| 725 | + | let x = form_layout[0].x + 1 + app.create_name.len() as u16; |
|
| 726 | + | let y = form_layout[0].y + 1; |
|
| 727 | + | frame.set_cursor_position((x, y)); |
|
| 728 | + | } |
|
| 729 | + | Focus::CreateContent | Focus::EditContent => { |
|
| 730 | + | let (cx, cy) = if app.wrap_content { |
|
| 731 | + | app.cursor_position_wrapped(inner_width) |
|
| 732 | + | } else { |
|
| 733 | + | let last_line = app.create_content.lines().last().unwrap_or(""); |
|
| 734 | + | let line_count = app.create_content.lines().count() |
|
| 735 | + | + if app.create_content.ends_with('\n') { 1 } else { 0 }; |
|
| 736 | + | let y_offset = if line_count == 0 { 0 } else { line_count - 1 }; |
|
| 737 | + | let col = if app.create_content.ends_with('\n') { |
|
| 738 | + | 0 |
|
| 739 | + | } else { |
|
| 740 | + | last_line.len() as u16 |
|
| 741 | + | }; |
|
| 742 | + | (col, y_offset as u16) |
|
| 743 | + | }; |
|
| 744 | + | app.auto_scroll_edit(cy, inner_height); |
|
| 745 | + | let screen_y = cy.saturating_sub(app.edit_scroll); |
|
| 746 | + | let x = content_inner.x + cx; |
|
| 747 | + | let y = content_inner.y + screen_y; |
|
| 748 | + | frame.set_cursor_position((x, y)); |
|
| 749 | + | } |
|
| 750 | + | _ => {} |
|
| 751 | + | } |
|
| 752 | + | ||
| 753 | + | } |
|
| 754 | + | _ => { |
|
| 755 | + | let highlighted = match app.selected_snippet() { |
|
| 756 | + | Some(s) => app.highlight_content(&s.name, &s.content), |
|
| 757 | + | None => Text::raw(""), |
|
| 758 | + | }; |
|
| 759 | + | ||
| 760 | + | let paragraph = Paragraph::new(highlighted) |
|
| 761 | + | .block( |
|
| 762 | + | Block::default() |
|
| 763 | + | .title(" Content ") |
|
| 764 | + | .borders(Borders::ALL) |
|
| 765 | + | .border_style(content_border_style), |
|
| 766 | + | ) |
|
| 767 | + | .scroll((app.content_scroll, 0)); |
|
| 768 | + | ||
| 769 | + | frame.render_widget(paragraph, chunks[1]); |
|
| 770 | + | } |
|
| 771 | + | } |
|
| 772 | + | ||
| 773 | + | let hints = match app.focus { |
|
| 774 | + | Focus::List => Line::from(vec![ |
|
| 775 | + | Span::styled("j/k", Style::default().fg(Color::Yellow)), |
|
| 776 | + | Span::raw(": Navigate "), |
|
| 777 | + | Span::styled("Enter", Style::default().fg(Color::Yellow)), |
|
| 778 | + | Span::raw(": View "), |
|
| 779 | + | Span::styled("y", Style::default().fg(Color::Yellow)), |
|
| 780 | + | Span::raw(": Copy "), |
|
| 781 | + | Span::styled("e", Style::default().fg(Color::Yellow)), |
|
| 782 | + | Span::raw(": Edit "), |
|
| 783 | + | Span::styled("d", Style::default().fg(Color::Yellow)), |
|
| 784 | + | Span::raw(": Delete "), |
|
| 785 | + | Span::styled("c", Style::default().fg(Color::Yellow)), |
|
| 786 | + | Span::raw(": Create "), |
|
| 787 | + | Span::styled("/", Style::default().fg(Color::Yellow)), |
|
| 788 | + | Span::raw(": Search "), |
|
| 789 | + | Span::styled("?", Style::default().fg(Color::Yellow)), |
|
| 790 | + | Span::raw(": Help "), |
|
| 791 | + | Span::styled("q", Style::default().fg(Color::Yellow)), |
|
| 792 | + | Span::raw(": Quit"), |
|
| 793 | + | ]), |
|
| 794 | + | Focus::Content => Line::from(vec![ |
|
| 795 | + | Span::styled("j/k", Style::default().fg(Color::Yellow)), |
|
| 796 | + | Span::raw(": Scroll "), |
|
| 797 | + | Span::styled("y", Style::default().fg(Color::Yellow)), |
|
| 798 | + | Span::raw(": Copy "), |
|
| 799 | + | Span::styled("e", Style::default().fg(Color::Yellow)), |
|
| 800 | + | Span::raw(": Edit "), |
|
| 801 | + | Span::styled("Esc", Style::default().fg(Color::Yellow)), |
|
| 802 | + | Span::raw(": Back "), |
|
| 803 | + | Span::styled("?", Style::default().fg(Color::Yellow)), |
|
| 804 | + | Span::raw(": Help"), |
|
| 805 | + | ]), |
|
| 806 | + | Focus::CreateName | Focus::CreateContent |
|
| 807 | + | | Focus::EditName | Focus::EditContent => Line::from(vec![ |
|
| 808 | + | Span::styled("Tab", Style::default().fg(Color::Yellow)), |
|
| 809 | + | Span::raw(": Switch field "), |
|
| 810 | + | Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)), |
|
| 811 | + | Span::raw(": Save "), |
|
| 812 | + | Span::styled("Ctrl+W", Style::default().fg(Color::Yellow)), |
|
| 813 | + | Span::raw(": Wrap "), |
|
| 814 | + | Span::styled("Esc", Style::default().fg(Color::Yellow)), |
|
| 815 | + | Span::raw(": Cancel"), |
|
| 816 | + | ]), |
|
| 817 | + | Focus::Search => Line::from(vec![ |
|
| 818 | + | Span::styled("Type", Style::default().fg(Color::Yellow)), |
|
| 819 | + | Span::raw(": Filter "), |
|
| 820 | + | Span::styled("Enter", Style::default().fg(Color::Yellow)), |
|
| 821 | + | Span::raw(": Select "), |
|
| 822 | + | Span::styled("Esc", Style::default().fg(Color::Yellow)), |
|
| 823 | + | Span::raw(": Cancel"), |
|
| 824 | + | ]), |
|
| 825 | + | }; |
|
| 826 | + | frame.render_widget(Paragraph::new(hints), outer[1]); |
|
| 827 | + | ||
| 828 | + | if let Some((msg, _)) = &app.status_message { |
|
| 829 | + | let area = frame.area(); |
|
| 830 | + | let msg_width = (msg.len() as u16 + 4).max(20).min(area.width.saturating_sub(4)); |
|
| 831 | + | let popup_area = ratatui::layout::Rect { |
|
| 832 | + | x: (area.width.saturating_sub(msg_width)) / 2, |
|
| 833 | + | y: (area.height.saturating_sub(3)) / 2, |
|
| 834 | + | width: msg_width, |
|
| 835 | + | height: 3, |
|
| 836 | + | }; |
|
| 837 | + | Clear.render(popup_area, frame.buffer_mut()); |
|
| 838 | + | let status_popup = Paragraph::new(Line::from(msg.as_str())) |
|
| 839 | + | .style(Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)) |
|
| 840 | + | .alignment(Alignment::Center) |
|
| 841 | + | .block( |
|
| 842 | + | Block::default() |
|
| 843 | + | .borders(Borders::ALL) |
|
| 844 | + | .border_style(Style::default().fg(Color::Green)), |
|
| 845 | + | ); |
|
| 846 | + | frame.render_widget(status_popup, popup_area); |
|
| 847 | + | } |
|
| 848 | + | ||
| 849 | + | if app.confirm_delete { |
|
| 850 | + | let delete_msg = match app.selected_snippet() { |
|
| 851 | + | Some(s) => format!("Delete {}? (y/n)", s.name), |
|
| 852 | + | None => "Delete snippet? (y/n)".to_string(), |
|
| 853 | + | }; |
|
| 854 | + | let area = frame.area(); |
|
| 855 | + | let msg_width = (delete_msg.len() as u16 + 4).max(24).min(area.width.saturating_sub(4)); |
|
| 856 | + | let popup_area = ratatui::layout::Rect { |
|
| 857 | + | x: (area.width.saturating_sub(msg_width)) / 2, |
|
| 858 | + | y: (area.height.saturating_sub(3)) / 2, |
|
| 859 | + | width: msg_width, |
|
| 860 | + | height: 3, |
|
| 861 | + | }; |
|
| 862 | + | Clear.render(popup_area, frame.buffer_mut()); |
|
| 863 | + | let confirm_popup = Paragraph::new(Line::from(delete_msg)) |
|
| 864 | + | .style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)) |
|
| 865 | + | .alignment(Alignment::Center) |
|
| 866 | + | .block( |
|
| 867 | + | Block::default() |
|
| 868 | + | .borders(Borders::ALL) |
|
| 869 | + | .border_style(Style::default().fg(Color::Red)), |
|
| 870 | + | ); |
|
| 871 | + | frame.render_widget(confirm_popup, popup_area); |
|
| 872 | + | } |
|
| 873 | + | ||
| 874 | + | if app.show_help { |
|
| 875 | + | let area = frame.area(); |
|
| 876 | + | let popup_width = 34u16.min(area.width.saturating_sub(4)); |
|
| 877 | + | let popup_height = 21u16.min(area.height.saturating_sub(4)); |
|
| 878 | + | let popup_area = ratatui::layout::Rect { |
|
| 879 | + | x: (area.width.saturating_sub(popup_width)) / 2, |
|
| 880 | + | y: (area.height.saturating_sub(popup_height)) / 2, |
|
| 881 | + | width: popup_width, |
|
| 882 | + | height: popup_height, |
|
| 883 | + | }; |
|
| 884 | + | ||
| 885 | + | let mut help_lines = vec![ |
|
| 886 | + | Line::from(""), |
|
| 887 | + | Line::from(vec![ |
|
| 888 | + | Span::styled( |
|
| 889 | + | " j/↓ ", |
|
| 890 | + | Style::default() |
|
| 891 | + | .fg(Color::Yellow) |
|
| 892 | + | .add_modifier(Modifier::BOLD), |
|
| 893 | + | ), |
|
| 894 | + | Span::raw("Move down / Scroll down"), |
|
| 895 | + | ]), |
|
| 896 | + | Line::from(vec![ |
|
| 897 | + | Span::styled( |
|
| 898 | + | " k/↑ ", |
|
| 899 | + | Style::default() |
|
| 900 | + | .fg(Color::Yellow) |
|
| 901 | + | .add_modifier(Modifier::BOLD), |
|
| 902 | + | ), |
|
| 903 | + | Span::raw("Move up / Scroll up"), |
|
| 904 | + | ]), |
|
| 905 | + | Line::from(vec![ |
|
| 906 | + | Span::styled( |
|
| 907 | + | " Enter", |
|
| 908 | + | Style::default() |
|
| 909 | + | .fg(Color::Yellow) |
|
| 910 | + | .add_modifier(Modifier::BOLD), |
|
| 911 | + | ), |
|
| 912 | + | Span::raw(" Focus content pane"), |
|
| 913 | + | ]), |
|
| 914 | + | Line::from(vec![ |
|
| 915 | + | Span::styled( |
|
| 916 | + | " Esc ", |
|
| 917 | + | Style::default() |
|
| 918 | + | .fg(Color::Yellow) |
|
| 919 | + | .add_modifier(Modifier::BOLD), |
|
| 920 | + | ), |
|
| 921 | + | Span::raw("Back / Quit"), |
|
| 922 | + | ]), |
|
| 923 | + | Line::from(vec![ |
|
| 924 | + | Span::styled( |
|
| 925 | + | " y ", |
|
| 926 | + | Style::default() |
|
| 927 | + | .fg(Color::Yellow) |
|
| 928 | + | .add_modifier(Modifier::BOLD), |
|
| 929 | + | ), |
|
| 930 | + | Span::raw("Copy snippet"), |
|
| 931 | + | ]), |
|
| 932 | + | Line::from(vec![ |
|
| 933 | + | Span::styled( |
|
| 934 | + | " Y ", |
|
| 935 | + | Style::default() |
|
| 936 | + | .fg(Color::Yellow) |
|
| 937 | + | .add_modifier(Modifier::BOLD), |
|
| 938 | + | ), |
|
| 939 | + | Span::raw("Copy link"), |
|
| 940 | + | ]), |
|
| 941 | + | Line::from(vec![ |
|
| 942 | + | Span::styled( |
|
| 943 | + | " o ", |
|
| 944 | + | Style::default() |
|
| 945 | + | .fg(Color::Yellow) |
|
| 946 | + | .add_modifier(Modifier::BOLD), |
|
| 947 | + | ), |
|
| 948 | + | Span::raw("Open in browser"), |
|
| 949 | + | ]), |
|
| 950 | + | Line::from(vec![ |
|
| 951 | + | Span::styled( |
|
| 952 | + | " d ", |
|
| 953 | + | Style::default() |
|
| 954 | + | .fg(Color::Yellow) |
|
| 955 | + | .add_modifier(Modifier::BOLD), |
|
| 956 | + | ), |
|
| 957 | + | Span::raw("Delete snippet"), |
|
| 958 | + | ]), |
|
| 959 | + | Line::from(vec![ |
|
| 960 | + | Span::styled( |
|
| 961 | + | " c ", |
|
| 962 | + | Style::default() |
|
| 963 | + | .fg(Color::Yellow) |
|
| 964 | + | .add_modifier(Modifier::BOLD), |
|
| 965 | + | ), |
|
| 966 | + | Span::raw("Create snippet"), |
|
| 967 | + | ]), |
|
| 968 | + | Line::from(vec![ |
|
| 969 | + | Span::styled( |
|
| 970 | + | " e ", |
|
| 971 | + | Style::default() |
|
| 972 | + | .fg(Color::Yellow) |
|
| 973 | + | .add_modifier(Modifier::BOLD), |
|
| 974 | + | ), |
|
| 975 | + | Span::raw("Edit snippet"), |
|
| 976 | + | ]), |
|
| 977 | + | Line::from(vec![ |
|
| 978 | + | Span::styled( |
|
| 979 | + | " / ", |
|
| 980 | + | Style::default() |
|
| 981 | + | .fg(Color::Yellow) |
|
| 982 | + | .add_modifier(Modifier::BOLD), |
|
| 983 | + | ), |
|
| 984 | + | Span::raw("Search snippets"), |
|
| 985 | + | ]), |
|
| 986 | + | Line::from(vec![ |
|
| 987 | + | Span::styled( |
|
| 988 | + | " ^W ", |
|
| 989 | + | Style::default() |
|
| 990 | + | .fg(Color::Yellow) |
|
| 991 | + | .add_modifier(Modifier::BOLD), |
|
| 992 | + | ), |
|
| 993 | + | Span::raw("Toggle word wrap (edit)"), |
|
| 994 | + | ]), |
|
| 995 | + | ]; |
|
| 996 | + | ||
| 997 | + | if app.is_remote { |
|
| 998 | + | help_lines.push(Line::from(vec![ |
|
| 999 | + | Span::styled( |
|
| 1000 | + | " r ", |
|
| 1001 | + | Style::default() |
|
| 1002 | + | .fg(Color::Yellow) |
|
| 1003 | + | .add_modifier(Modifier::BOLD), |
|
| 1004 | + | ), |
|
| 1005 | + | Span::raw("Refresh snippets"), |
|
| 1006 | + | ])); |
|
| 1007 | + | } |
|
| 1008 | + | ||
| 1009 | + | help_lines.extend([ |
|
| 1010 | + | Line::from(vec![ |
|
| 1011 | + | Span::styled( |
|
| 1012 | + | " q ", |
|
| 1013 | + | Style::default() |
|
| 1014 | + | .fg(Color::Yellow) |
|
| 1015 | + | .add_modifier(Modifier::BOLD), |
|
| 1016 | + | ), |
|
| 1017 | + | Span::raw("Quit"), |
|
| 1018 | + | ]), |
|
| 1019 | + | Line::from(vec![ |
|
| 1020 | + | Span::styled( |
|
| 1021 | + | " ? ", |
|
| 1022 | + | Style::default() |
|
| 1023 | + | .fg(Color::Yellow) |
|
| 1024 | + | .add_modifier(Modifier::BOLD), |
|
| 1025 | + | ), |
|
| 1026 | + | Span::raw("Toggle this help"), |
|
| 1027 | + | ]), |
|
| 1028 | + | Line::from(""), |
|
| 1029 | + | Line::from(Span::styled( |
|
| 1030 | + | " Press any key to close", |
|
| 1031 | + | Style::default().fg(Color::DarkGray), |
|
| 1032 | + | )), |
|
| 1033 | + | ]); |
|
| 1034 | + | ||
| 1035 | + | let help_text = Text::from(help_lines); |
|
| 1036 | + | ||
| 1037 | + | Clear.render(popup_area, frame.buffer_mut()); |
|
| 1038 | + | let help = Paragraph::new(help_text).block( |
|
| 1039 | + | Block::default() |
|
| 1040 | + | .title(" Keybindings ") |
|
| 1041 | + | .borders(Borders::ALL) |
|
| 1042 | + | .border_style(Style::default().fg(Color::Yellow)), |
|
| 1043 | + | ); |
|
| 1044 | + | frame.render_widget(help, popup_area); |
|
| 1045 | + | } |
|
| 1046 | + | })?; |
|
| 1047 | + | ||
| 1048 | + | if event::poll(Duration::from_millis(100))? { |
|
| 1049 | + | if let Event::Key(key) = event::read()? { |
|
| 1050 | + | if app.show_help { |
|
| 1051 | + | app.show_help = false; |
|
| 1052 | + | } else if app.status_message.is_some() { |
|
| 1053 | + | app.status_message = None; |
|
| 1054 | + | } else if app.confirm_delete { |
|
| 1055 | + | if key.code == KeyCode::Char('y') { |
|
| 1056 | + | app.delete_selected(backend); |
|
| 1057 | + | } |
|
| 1058 | + | app.confirm_delete = false; |
|
| 1059 | + | } else { |
|
| 1060 | + | match app.focus { |
|
| 1061 | + | Focus::List => match key.code { |
|
| 1062 | + | KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true, |
|
| 1063 | + | KeyCode::Char('j') | KeyCode::Down => app.move_down(), |
|
| 1064 | + | KeyCode::Char('k') | KeyCode::Up => app.move_up(), |
|
| 1065 | + | KeyCode::Char('y') => app.copy_selected(), |
|
| 1066 | + | KeyCode::Char('Y') => app.copy_link(), |
|
| 1067 | + | KeyCode::Char('d') => app.confirm_delete = true, |
|
| 1068 | + | KeyCode::Char('c') => app.start_create(), |
|
| 1069 | + | KeyCode::Char('e') => app.start_edit(), |
|
| 1070 | + | KeyCode::Char('/') => app.start_search(), |
|
| 1071 | + | KeyCode::Char('o') => app.open_in_browser(), |
|
| 1072 | + | KeyCode::Char('r') if app.is_remote => app.refresh(backend), |
|
| 1073 | + | KeyCode::Char('?') => app.show_help = true, |
|
| 1074 | + | KeyCode::Enter | KeyCode::Char('l') => { |
|
| 1075 | + | if app.selected_snippet().is_some() { |
|
| 1076 | + | app.focus = Focus::Content; |
|
| 1077 | + | } |
|
| 1078 | + | } |
|
| 1079 | + | _ => {} |
|
| 1080 | + | }, |
|
| 1081 | + | Focus::Content => match key.code { |
|
| 1082 | + | KeyCode::Char(' ') | KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('h') => { |
|
| 1083 | + | app.focus = Focus::List; |
|
| 1084 | + | } |
|
| 1085 | + | KeyCode::Char('j') | KeyCode::Down => { |
|
| 1086 | + | app.scroll_down(content_line_count); |
|
| 1087 | + | } |
|
| 1088 | + | KeyCode::Char('k') | KeyCode::Up => app.scroll_up(), |
|
| 1089 | + | KeyCode::Char('y') => app.copy_selected(), |
|
| 1090 | + | KeyCode::Char('Y') => app.copy_link(), |
|
| 1091 | + | KeyCode::Char('e') => app.start_edit(), |
|
| 1092 | + | KeyCode::Char('o') => app.open_in_browser(), |
|
| 1093 | + | KeyCode::Char('?') => app.show_help = true, |
|
| 1094 | + | _ => {} |
|
| 1095 | + | }, |
|
| 1096 | + | Focus::CreateName => { |
|
| 1097 | + | if key.modifiers.contains(KeyModifiers::CONTROL) |
|
| 1098 | + | && key.code == KeyCode::Char('s') |
|
| 1099 | + | { |
|
| 1100 | + | app.save_create(backend); |
|
| 1101 | + | } else { |
|
| 1102 | + | match key.code { |
|
| 1103 | + | KeyCode::Esc => app.cancel_create(), |
|
| 1104 | + | KeyCode::Enter | KeyCode::Tab => { |
|
| 1105 | + | app.focus = Focus::CreateContent |
|
| 1106 | + | } |
|
| 1107 | + | KeyCode::Backspace => { |
|
| 1108 | + | app.create_name.pop(); |
|
| 1109 | + | } |
|
| 1110 | + | KeyCode::Char(c) => app.create_name.push(c), |
|
| 1111 | + | _ => {} |
|
| 1112 | + | } |
|
| 1113 | + | } |
|
| 1114 | + | } |
|
| 1115 | + | Focus::CreateContent => { |
|
| 1116 | + | if key.modifiers.contains(KeyModifiers::CONTROL) { |
|
| 1117 | + | match key.code { |
|
| 1118 | + | KeyCode::Char('s') => app.save_create(backend), |
|
| 1119 | + | KeyCode::Char('w') => { |
|
| 1120 | + | app.wrap_content = !app.wrap_content; |
|
| 1121 | + | app.edit_scroll = 0; |
|
| 1122 | + | } |
|
| 1123 | + | _ => {} |
|
| 1124 | + | } |
|
| 1125 | + | } else { |
|
| 1126 | + | match key.code { |
|
| 1127 | + | KeyCode::Esc => app.cancel_create(), |
|
| 1128 | + | KeyCode::Tab => app.focus = Focus::CreateName, |
|
| 1129 | + | KeyCode::Enter => app.create_content.push('\n'), |
|
| 1130 | + | KeyCode::Backspace => { |
|
| 1131 | + | app.create_content.pop(); |
|
| 1132 | + | } |
|
| 1133 | + | KeyCode::Char(c) => app.create_content.push(c), |
|
| 1134 | + | _ => {} |
|
| 1135 | + | } |
|
| 1136 | + | } |
|
| 1137 | + | } |
|
| 1138 | + | Focus::EditName => { |
|
| 1139 | + | if key.modifiers.contains(KeyModifiers::CONTROL) |
|
| 1140 | + | && key.code == KeyCode::Char('s') |
|
| 1141 | + | { |
|
| 1142 | + | app.save_edit(backend); |
|
| 1143 | + | } else { |
|
| 1144 | + | match key.code { |
|
| 1145 | + | KeyCode::Esc => app.cancel_edit(), |
|
| 1146 | + | KeyCode::Enter | KeyCode::Tab => { |
|
| 1147 | + | app.focus = Focus::EditContent |
|
| 1148 | + | } |
|
| 1149 | + | KeyCode::Backspace => { |
|
| 1150 | + | app.create_name.pop(); |
|
| 1151 | + | } |
|
| 1152 | + | KeyCode::Char(c) => app.create_name.push(c), |
|
| 1153 | + | _ => {} |
|
| 1154 | + | } |
|
| 1155 | + | } |
|
| 1156 | + | } |
|
| 1157 | + | Focus::EditContent => { |
|
| 1158 | + | if key.modifiers.contains(KeyModifiers::CONTROL) { |
|
| 1159 | + | match key.code { |
|
| 1160 | + | KeyCode::Char('s') => app.save_edit(backend), |
|
| 1161 | + | KeyCode::Char('w') => { |
|
| 1162 | + | app.wrap_content = !app.wrap_content; |
|
| 1163 | + | app.edit_scroll = 0; |
|
| 1164 | + | } |
|
| 1165 | + | _ => {} |
|
| 1166 | + | } |
|
| 1167 | + | } else { |
|
| 1168 | + | match key.code { |
|
| 1169 | + | KeyCode::Esc => app.cancel_edit(), |
|
| 1170 | + | KeyCode::Tab => app.focus = Focus::EditName, |
|
| 1171 | + | KeyCode::Enter => app.create_content.push('\n'), |
|
| 1172 | + | KeyCode::Backspace => { |
|
| 1173 | + | app.create_content.pop(); |
|
| 1174 | + | } |
|
| 1175 | + | KeyCode::Char(c) => app.create_content.push(c), |
|
| 1176 | + | _ => {} |
|
| 1177 | + | } |
|
| 1178 | + | } |
|
| 1179 | + | } |
|
| 1180 | + | Focus::Search => match key.code { |
|
| 1181 | + | KeyCode::Esc => app.cancel_search(), |
|
| 1182 | + | KeyCode::Enter => app.confirm_search(), |
|
| 1183 | + | KeyCode::Backspace => { |
|
| 1184 | + | app.search_query.pop(); |
|
| 1185 | + | app.update_search_filter(); |
|
| 1186 | + | } |
|
| 1187 | + | KeyCode::Char(c) => { |
|
| 1188 | + | app.search_query.push(c); |
|
| 1189 | + | app.update_search_filter(); |
|
| 1190 | + | } |
|
| 1191 | + | _ => {} |
|
| 1192 | + | }, |
|
| 1193 | + | } |
|
| 1194 | + | } |
|
| 1195 | + | } |
|
| 1196 | + | } |
|
| 1197 | + | } |
|
| 1198 | + | ||
| 1199 | + | Ok(()) |
|
| 1200 | + | } |
| 1 | + | * { |
|
| 2 | + | padding: 0; |
|
| 3 | + | margin: 0; |
|
| 4 | + | box-sizing: border-box; |
|
| 5 | + | font-family: "Commit Mono", monospace, sans-serif; |
|
| 6 | + | scrollbar-width: none; |
|
| 7 | + | -ms-overflow-style: none; |
|
| 8 | + | } |
|
| 9 | + | ||
| 10 | + | html { |
|
| 11 | + | background: #121113; |
|
| 12 | + | color: #ffffff; |
|
| 13 | + | } |
|
| 14 | + | ||
| 15 | + | html::-webkit-scrollbar { |
|
| 16 | + | display: none; |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | body { |
|
| 20 | + | display: flex; |
|
| 21 | + | flex-direction: column; |
|
| 22 | + | justify-content: start; |
|
| 23 | + | align-items: start; |
|
| 24 | + | gap: 1.5rem; |
|
| 25 | + | min-height: 100vh; |
|
| 26 | + | max-width: 700px; |
|
| 27 | + | margin: auto; |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | .header { |
|
| 31 | + | display: flex; |
|
| 32 | + | text-decoration: none; |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | .nav { |
|
| 36 | + | display: flex; |
|
| 37 | + | align-items: center; |
|
| 38 | + | justify-content: space-between; |
|
| 39 | + | width: 100%; |
|
| 40 | + | margin-top: 2rem; |
|
| 41 | + | } |
|
| 42 | + | ||
| 43 | + | .icon { |
|
| 44 | + | display: flex; |
|
| 45 | + | align-items: center; |
|
| 46 | + | justify-content: center; |
|
| 47 | + | color: #878787; |
|
| 48 | + | width: 24px; |
|
| 49 | + | height: 24px; |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | .icon svg { |
|
| 53 | + | width: 24px; |
|
| 54 | + | height: 24px; |
|
| 55 | + | } |
|
| 56 | + | ||
| 57 | + | .icon svg path { |
|
| 58 | + | fill: #878787; |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | .icon:hover svg path { |
|
| 62 | + | fill: white; |
|
| 63 | + | } |
|
| 64 | + | ||
| 65 | + | #snippetForm { |
|
| 66 | + | display: flex; |
|
| 67 | + | flex-direction: column; |
|
| 68 | + | gap: 1rem; |
|
| 69 | + | width: 100%; |
|
| 70 | + | } |
|
| 71 | + | ||
| 72 | + | #snippetForm input { |
|
| 73 | + | background: #121113; |
|
| 74 | + | color: #ffffff; |
|
| 75 | + | border: 1px solid white; |
|
| 76 | + | padding: 4px; |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | #authForm input { |
|
| 80 | + | background: #121113; |
|
| 81 | + | color: #ffffff; |
|
| 82 | + | border: 1px solid white; |
|
| 83 | + | padding: 4px; |
|
| 84 | + | } |
|
| 85 | + | ||
| 86 | + | textarea { |
|
| 87 | + | background: #121113; |
|
| 88 | + | color: #ffffff; |
|
| 89 | + | width: 100%; |
|
| 90 | + | min-height: 400px; |
|
| 91 | + | padding: 6px; |
|
| 92 | + | border: 1px solid white; |
|
| 93 | + | } |
|
| 94 | + | ||
| 95 | + | .code-container { |
|
| 96 | + | border: 1px solid white; |
|
| 97 | + | height: 400px; |
|
| 98 | + | overflow: auto; |
|
| 99 | + | } |
|
| 100 | + | ||
| 101 | + | .code-container pre { |
|
| 102 | + | background-color: #121113 !important; |
|
| 103 | + | padding: 6px; |
|
| 104 | + | margin: 0; |
|
| 105 | + | min-height: 100%; |
|
| 106 | + | font-size: 13px; |
|
| 107 | + | line-height: 1.4; |
|
| 108 | + | } |
|
| 109 | + | ||
| 110 | + | button { |
|
| 111 | + | background: #121113; |
|
| 112 | + | color: #ffffff; |
|
| 113 | + | padding: 6px; |
|
| 114 | + | border: 1px solid white; |
|
| 115 | + | cursor: pointer; |
|
| 116 | + | width: fit-content; |
|
| 117 | + | } |
|
| 118 | + | ||
| 119 | + | a { |
|
| 120 | + | background: #121113; |
|
| 121 | + | color: #ffffff; |
|
| 122 | + | } |
|
| 123 | + | ||
| 124 | + | @media (max-width: 480px) { |
|
| 125 | + | body { |
|
| 126 | + | padding: 1rem; |
|
| 127 | + | gap: 1rem; |
|
| 128 | + | } |
|
| 129 | + | } |
|
| 130 | + | ||
| 131 | + | #snippetList { |
|
| 132 | + | flex-direction: column; |
|
| 133 | + | gap: 0; |
|
| 134 | + | } |
|
| 135 | + | ||
| 136 | + | .snippet-item { |
|
| 137 | + | display: flex; |
|
| 138 | + | justify-content: space-between; |
|
| 139 | + | align-items: center; |
|
| 140 | + | padding: 8px; |
|
| 141 | + | border: 1px solid white; |
|
| 142 | + | margin-top: -1px; |
|
| 143 | + | text-decoration: none; |
|
| 144 | + | } |
|
| 145 | + | ||
| 146 | + | .snippet-item:hover { |
|
| 147 | + | background: #1e1d1f; |
|
| 148 | + | } |
|
| 149 | + | ||
| 150 | + | .snippet-id { |
|
| 151 | + | color: #878787; |
|
| 152 | + | font-size: 13px; |
|
| 153 | + | } |
|
| 154 | + | ||
| 155 | + | @font-face { |
|
| 156 | + | font-family: "Commit Mono"; |
|
| 157 | + | src: url("/assets/fonts/CommitMono-400-Regular.otf") format("opentype"); |
|
| 158 | + | font-weight: 400; |
|
| 159 | + | font-style: normal; |
|
| 160 | + | } |
|
| 161 | + | ||
| 162 | + | @font-face { |
|
| 163 | + | font-family: "Commit Mono"; |
|
| 164 | + | src: url("/assets/fonts/CommitMono-700-Regular.otf") format("opentype"); |
|
| 165 | + | font-weight: 700; |
|
| 166 | + | font-style: normal; |
|
| 167 | + | } |
| 1 | + | <!doctype html> |
|
| 2 | + | <html lang="en"> |
|
| 3 | + | <head> |
|
| 4 | + | <meta charset="UTF-8" /> |
|
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | + | <meta name="theme-color" content="#121113" /> |
|
| 7 | + | <link rel="stylesheet" href="/static/styles.css" /> |
|
| 8 | + | <link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png"> |
|
| 9 | + | <link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png"> |
|
| 10 | + | <link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png"> |
|
| 11 | + | <link rel="manifest" href="/assets/site.webmanifest"> |
|
| 12 | + | ||
| 13 | + | <title>Sipp - Admin</title> |
|
| 14 | + | <meta name="description" content="Minimal Code Sharing"> |
|
| 15 | + | ||
| 16 | + | <meta property="og:url" content="https://sipp.so"> |
|
| 17 | + | <meta property="og:type" content="website"> |
|
| 18 | + | <meta property="og:title" content="Sipps"> |
|
| 19 | + | <meta property="og:description" content="Minimal Code Sharing"> |
|
| 20 | + | <meta property="og:image" content="https://sipp.so/assets/og.png"> |
|
| 21 | + | ||
| 22 | + | <meta name="twitter:card" content="summary_large_image"> |
|
| 23 | + | <meta property="twitter:domain" content="sipp.so"> |
|
| 24 | + | <meta property="twitter:url" content="https://sipp.so"> |
|
| 25 | + | <meta name="twitter:title" content="Sipps"> |
|
| 26 | + | <meta name="twitter:description" content="Minimal Code Sharing"> |
|
| 27 | + | <meta name="twitter:image" content="https://sipp.so/assets/og.png"> |
|
| 28 | + | </head> |
|
| 29 | + | <body> |
|
| 30 | + | ||
| 31 | + | <div class="nav"> |
|
| 32 | + | <a href="/" class="header"> |
|
| 33 | + | <h1>SIPP</h1> |
|
| 34 | + | </a> |
|
| 35 | + | ||
| 36 | + | <a class="icon" target="_blank" href="https://github.com/stevedylandev/sipp"> |
|
| 37 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> |
|
| 38 | + | <title>GitHub</title> |
|
| 39 | + | <path d="m21.838 11.677l-9.549-9.58c-.129-.13-.451-.13-.645 0L9 4.742l2.452 2.452c.193-.097.419-.13.645-.13c.903 0 1.58.742 1.58 1.581c0 .226-.032.452-.129.645l1.968 1.968c.194-.097.42-.129.645-.129c.904 0 1.58.742 1.58 1.58c0 .904-.741 1.581-1.58 1.581c-.903 0-1.58-.742-1.58-1.58c0-.226.032-.452.129-.646l-1.968-1.967h-.032v3.71c.58.258 1 .806 1 1.483c0 .904-.742 1.581-1.581 1.581c-.903 0-1.58-.742-1.58-1.58c0-.678.419-1.259 1-1.485v-3.612c-.581-.259-1-.807-1-1.484c0-.226.032-.452.128-.645L8.225 5.613l-6.097 6.064c-.129.13-.129.452 0 .646l9.58 9.58c.13.13.452.13.646 0l9.548-9.58a.59.59 0 0 0-.064-.646"/> |
|
| 40 | + | </svg> |
|
| 41 | + | </a> |
|
| 42 | + | </div> |
|
| 43 | + | ||
| 44 | + | <div id="authForm" style="display: flex; gap: 1rem; width: 100%;"> |
|
| 45 | + | <input placeholder="API Key" type="password" id="apiKey" style="flex: 1;"> |
|
| 46 | + | <button id="loadBtn" onclick="loadSnippets()">Load Snippets</button> |
|
| 47 | + | </div> |
|
| 48 | + | ||
| 49 | + | <div id="error" style="display: none; color: #ff6b6b;"></div> |
|
| 50 | + | ||
| 51 | + | <div id="snippetList" style="display: none; width: 100%;"></div> |
|
| 52 | + | ||
| 53 | + | <script> |
|
| 54 | + | async function loadSnippets() { |
|
| 55 | + | const apiKey = document.getElementById('apiKey').value; |
|
| 56 | + | const errorEl = document.getElementById('error'); |
|
| 57 | + | const listEl = document.getElementById('snippetList'); |
|
| 58 | + | const loadBtn = document.getElementById('loadBtn'); |
|
| 59 | + | ||
| 60 | + | errorEl.style.display = 'none'; |
|
| 61 | + | listEl.style.display = 'none'; |
|
| 62 | + | loadBtn.textContent = 'Loading...'; |
|
| 63 | + | loadBtn.disabled = true; |
|
| 64 | + | ||
| 65 | + | try { |
|
| 66 | + | const res = await fetch('/api/snippets', { |
|
| 67 | + | headers: { 'x-api-key': apiKey } |
|
| 68 | + | }); |
|
| 69 | + | ||
| 70 | + | if (!res.ok) { |
|
| 71 | + | const data = await res.json(); |
|
| 72 | + | throw new Error(data.error || 'Failed to load snippets'); |
|
| 73 | + | } |
|
| 74 | + | ||
| 75 | + | const snippets = await res.json(); |
|
| 76 | + | ||
| 77 | + | if (snippets.length === 0) { |
|
| 78 | + | listEl.innerHTML = '<p>No snippets found.</p>'; |
|
| 79 | + | } else { |
|
| 80 | + | listEl.innerHTML = snippets.map(s => |
|
| 81 | + | `<a class="snippet-item" href="/s/${s.short_id}">` + |
|
| 82 | + | `<span class="snippet-name">${s.name}</span>` + |
|
| 83 | + | `<span class="snippet-id">/s/${s.short_id}</span>` + |
|
| 84 | + | `</a>` |
|
| 85 | + | ).join(''); |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | listEl.style.display = 'flex'; |
|
| 89 | + | } catch (err) { |
|
| 90 | + | errorEl.textContent = err.message; |
|
| 91 | + | errorEl.style.display = 'block'; |
|
| 92 | + | } finally { |
|
| 93 | + | loadBtn.textContent = 'Load Snippets'; |
|
| 94 | + | loadBtn.disabled = false; |
|
| 95 | + | } |
|
| 96 | + | } |
|
| 97 | + | ||
| 98 | + | document.getElementById('apiKey').addEventListener('keydown', (e) => { |
|
| 99 | + | if (e.key === 'Enter') { |
|
| 100 | + | e.preventDefault(); |
|
| 101 | + | loadSnippets(); |
|
| 102 | + | } |
|
| 103 | + | }); |
|
| 104 | + | </script> |
|
| 105 | + | </body> |
|
| 106 | + | </html> |
| 1 | + | <!doctype html> |
|
| 2 | + | <html lang="en"> |
|
| 3 | + | <head> |
|
| 4 | + | <meta charset="UTF-8" /> |
|
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | + | <meta name="theme-color" content="#121113" /> |
|
| 7 | + | <link rel="stylesheet" href="/static/styles.css" /> |
|
| 8 | + | <link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png"> |
|
| 9 | + | <link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png"> |
|
| 10 | + | <link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png"> |
|
| 11 | + | <link rel="manifest" href="/assets/site.webmanifest"> |
|
| 12 | + | ||
| 13 | + | <title>Sipp</title> |
|
| 14 | + | <meta name="description" content="Minimal Code Sharing"> |
|
| 15 | + | ||
| 16 | + | <meta property="og:url" content="https://sipp.so"> |
|
| 17 | + | <meta property="og:type" content="website"> |
|
| 18 | + | <meta property="og:title" content="Sipps"> |
|
| 19 | + | <meta property="og:description" content="Minimal Code Sharing"> |
|
| 20 | + | <meta property="og:image" content="https://sipp.so/assets/og.png"> |
|
| 21 | + | ||
| 22 | + | <meta name="twitter:card" content="summary_large_image"> |
|
| 23 | + | <meta property="twitter:domain" content="sipp.so"> |
|
| 24 | + | <meta property="twitter:url" content="https://sipp.so"> |
|
| 25 | + | <meta name="twitter:title" content="Sipps"> |
|
| 26 | + | <meta name="twitter:description" content="Minimal Code Sharing"> |
|
| 27 | + | <meta name="twitter:image" content="https://sipp.so/assets/og.png"> |
|
| 28 | + | </head> |
|
| 29 | + | <body> |
|
| 30 | + | ||
| 31 | + | <div class="nav"> |
|
| 32 | + | <a href="/" class="header"> |
|
| 33 | + | <h1>SIPP</h1> |
|
| 34 | + | </a> |
|
| 35 | + | ||
| 36 | + | <a class="icon" target="_blank" href="https://github.com/stevedylandev/sipp"> |
|
| 37 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> |
|
| 38 | + | <title>GitHub</title> |
|
| 39 | + | <path d="m21.838 11.677l-9.549-9.58c-.129-.13-.451-.13-.645 0L9 4.742l2.452 2.452c.193-.097.419-.13.645-.13c.903 0 1.58.742 1.58 1.581c0 .226-.032.452-.129.645l1.968 1.968c.194-.097.42-.129.645-.129c.904 0 1.58.742 1.58 1.58c0 .904-.741 1.581-1.58 1.581c-.903 0-1.58-.742-1.58-1.58c0-.226.032-.452.129-.646l-1.968-1.967h-.032v3.71c.58.258 1 .806 1 1.483c0 .904-.742 1.581-1.581 1.581c-.903 0-1.58-.742-1.58-1.58c0-.678.419-1.259 1-1.485v-3.612c-.581-.259-1-.807-1-1.484c0-.226.032-.452.128-.645L8.225 5.613l-6.097 6.064c-.129.13-.129.452 0 .646l9.58 9.58c.13.13.452.13.646 0l9.548-9.58a.59.59 0 0 0-.064-.646"/> |
|
| 40 | + | </svg> |
|
| 41 | + | </a> |
|
| 42 | + | </div> |
|
| 43 | + | ||
| 44 | + | ||
| 45 | + | <form id="snippetForm" method="POST" action="/snippets"> |
|
| 46 | + | <div> |
|
| 47 | + | <input placeholder="index.ts" type="text" id="name" name="name" required> |
|
| 48 | + | </div> |
|
| 49 | + | ||
| 50 | + | <div> |
|
| 51 | + | <textarea placeholder="// paste your code here" id="content" name="content" required></textarea> |
|
| 52 | + | </div> |
|
| 53 | + | ||
| 54 | + | <button type="submit">Create Snippet</button> |
|
| 55 | + | </form> |
|
| 56 | + | ||
| 57 | + | <script> |
|
| 58 | + | document.getElementById('content').addEventListener('keydown', (e) => { |
|
| 59 | + | if (e.metaKey && e.key === 'Enter' || e.ctrlKey && e.key === 'Enter') { |
|
| 60 | + | e.preventDefault(); |
|
| 61 | + | document.getElementById('snippetForm').requestSubmit(); |
|
| 62 | + | } |
|
| 63 | + | }); |
|
| 64 | + | </script> |
|
| 65 | + | </body> |
|
| 66 | + | </html> |
| 1 | + | <!doctype html> |
|
| 2 | + | <html lang="en"> |
|
| 3 | + | <head> |
|
| 4 | + | <meta charset="UTF-8" /> |
|
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | + | <link rel="stylesheet" href="/static/styles.css" /> |
|
| 7 | + | <meta name="theme-color" content="#121113" /> |
|
| 8 | + | <link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png"> |
|
| 9 | + | <link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png"> |
|
| 10 | + | <link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png"> |
|
| 11 | + | <link rel="manifest" href="/assets/site.webmanifest"> |
|
| 12 | + | ||
| 13 | + | <title>{{ name }} | Sipp</title> |
|
| 14 | + | <meta name="description" content="Minimal Code Sharing"> |
|
| 15 | + | ||
| 16 | + | <meta property="og:url" content="https://sipp.so"> |
|
| 17 | + | <meta property="og:type" content="website"> |
|
| 18 | + | <meta property="og:title" content="Sipp | {{ name }}"> |
|
| 19 | + | <meta property="og:description" content="Minimal Code Sharing"> |
|
| 20 | + | <meta property="og:image" content="https://sipp.so/assets/og.png"> |
|
| 21 | + | ||
| 22 | + | <meta name="twitter:card" content="summary_large_image"> |
|
| 23 | + | <meta property="twitter:domain" content="sipp.so"> |
|
| 24 | + | <meta property="twitter:url" content="https://sipp.so"> |
|
| 25 | + | <meta name="twitter:title" content="Sipp | {{ name }}"> |
|
| 26 | + | <meta name="twitter:description" content="Minimal Code Sharing"> |
|
| 27 | + | <meta name="twitter:image" content="https://sipp.so/assets/og.png"> |
|
| 28 | + | ||
| 29 | + | </head> |
|
| 30 | + | <body> |
|
| 31 | + | <div class="nav"> |
|
| 32 | + | <a href="/" class="header"> |
|
| 33 | + | <h1>SIPP</h1> |
|
| 34 | + | </a> |
|
| 35 | + | ||
| 36 | + | <a class="icon" target="_blank" href="https://github.com/stevedylandev/sipp"> |
|
| 37 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> |
|
| 38 | + | <title>GitHub</title> |
|
| 39 | + | <path d="m21.838 11.677l-9.549-9.58c-.129-.13-.451-.13-.645 0L9 4.742l2.452 2.452c.193-.097.419-.13.645-.13c.903 0 1.58.742 1.58 1.581c0 .226-.032.452-.129.645l1.968 1.968c.194-.097.42-.129.645-.129c.904 0 1.58.742 1.58 1.58c0 .904-.741 1.581-1.58 1.581c-.903 0-1.58-.742-1.58-1.58c0-.226.032-.452.129-.646l-1.968-1.967h-.032v3.71c.58.258 1 .806 1 1.483c0 .904-.742 1.581-1.581 1.581c-.903 0-1.58-.742-1.58-1.58c0-.678.419-1.259 1-1.485v-3.612c-.581-.259-1-.807-1-1.484c0-.226.032-.452.128-.645L8.225 5.613l-6.097 6.064c-.129.13-.129.452 0 .646l9.58 9.58c.13.13.452.13.646 0l9.548-9.58a.59.59 0 0 0-.064-.646"/> |
|
| 40 | + | </svg> |
|
| 41 | + | </a> |
|
| 42 | + | </div> |
|
| 43 | + | ||
| 44 | + | <div id="snippetForm"> |
|
| 45 | + | <label id="snippetName">{{ name }}</label> |
|
| 46 | + | <div class="code-container">{{ highlighted_content|safe }}</div> |
|
| 47 | + | <textarea id="content" style="display:none;">{{ content }}</textarea> |
|
| 48 | + | <div class="button-group"> |
|
| 49 | + | <button type="button" id="copyLinkBtn" data-original-text="Copy Link">Copy Link</button> |
|
| 50 | + | <button type="button" id="copyContentBtn" data-original-text="Copy Content">Copy Content</button> |
|
| 51 | + | <button type="button" id="createNewBtn">Create New Snippet</button> |
|
| 52 | + | </div> |
|
| 53 | + | </div> |
|
| 54 | + | ||
| 55 | + | <script> |
|
| 56 | + | async function copyToClipboard(text, button) { |
|
| 57 | + | try { |
|
| 58 | + | await navigator.clipboard.writeText(text); |
|
| 59 | + | showButtonFeedback(button, '\u2714 Copied', 'success'); |
|
| 60 | + | } catch (error) { |
|
| 61 | + | console.error('Copy failed:', error); |
|
| 62 | + | showButtonFeedback(button, '\u2718 Failed', 'error'); |
|
| 63 | + | ||
| 64 | + | try { |
|
| 65 | + | const textArea = document.createElement('textarea'); |
|
| 66 | + | textArea.value = text; |
|
| 67 | + | textArea.style.position = 'fixed'; |
|
| 68 | + | textArea.style.opacity = '0'; |
|
| 69 | + | document.body.appendChild(textArea); |
|
| 70 | + | textArea.select(); |
|
| 71 | + | document.execCommand('copy'); |
|
| 72 | + | document.body.removeChild(textArea); |
|
| 73 | + | showButtonFeedback(button, '\u2714 Copied', 'success'); |
|
| 74 | + | } catch (fallbackError) { |
|
| 75 | + | showButtonFeedback(button, '\u2718 Failed', 'error'); |
|
| 76 | + | } |
|
| 77 | + | } |
|
| 78 | + | } |
|
| 79 | + | ||
| 80 | + | function showButtonFeedback(button, message, type = 'success') { |
|
| 81 | + | const originalText = button.dataset.originalText || button.textContent; |
|
| 82 | + | const originalDisabled = button.disabled; |
|
| 83 | + | ||
| 84 | + | if (!button.dataset.originalText) { |
|
| 85 | + | button.dataset.originalText = originalText; |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | button.textContent = message; |
|
| 89 | + | button.disabled = true; |
|
| 90 | + | button.classList.add(`copy-${type}`); |
|
| 91 | + | ||
| 92 | + | setTimeout(() => { |
|
| 93 | + | button.textContent = originalText; |
|
| 94 | + | button.disabled = originalDisabled; |
|
| 95 | + | button.classList.remove(`copy-${type}`); |
|
| 96 | + | }, 1000); |
|
| 97 | + | } |
|
| 98 | + | ||
| 99 | + | document.getElementById('copyContentBtn').addEventListener('click', async () => { |
|
| 100 | + | const content = document.getElementById('content').value; |
|
| 101 | + | const button = document.getElementById('copyContentBtn'); |
|
| 102 | + | await copyToClipboard(content, button); |
|
| 103 | + | }); |
|
| 104 | + | ||
| 105 | + | document.getElementById('copyLinkBtn').addEventListener('click', async () => { |
|
| 106 | + | const currentUrl = window.location.href; |
|
| 107 | + | const button = document.getElementById('copyLinkBtn'); |
|
| 108 | + | await copyToClipboard(currentUrl, button); |
|
| 109 | + | }); |
|
| 110 | + | ||
| 111 | + | document.getElementById('createNewBtn').addEventListener('click', () => { |
|
| 112 | + | window.location.href = '/'; |
|
| 113 | + | }); |
|
| 114 | + | </script> |
|
| 115 | + | </body> |
|
| 116 | + | </html> |
| 1 | + | [package] |
|
| 2 | + | name = "andromeda-auth" |
|
| 3 | + | version = "0.1.0" |
|
| 4 | + | edition = "2024" |
|
| 5 | + | ||
| 6 | + | [dependencies] |
|
| 7 | + | subtle = { workspace = true } |
|
| 8 | + | rand = { workspace = true } |
|
| 9 | + | axum = { workspace = true } |
| 1 | + | use rand::RngCore; |
|
| 2 | + | use subtle::ConstantTimeEq; |
|
| 3 | + | ||
| 4 | + | /// Constant-time password comparison to prevent timing attacks. |
|
| 5 | + | /// Pads/truncates both sides to a fixed 256-byte buffer so length |
|
| 6 | + | /// differences don't leak via timing. |
|
| 7 | + | pub fn verify_password(input: &str, expected: &str) -> bool { |
|
| 8 | + | const LEN: usize = 256; |
|
| 9 | + | let mut a = [0u8; LEN]; |
|
| 10 | + | let mut b = [0u8; LEN]; |
|
| 11 | + | let ib = input.as_bytes(); |
|
| 12 | + | let eb = expected.as_bytes(); |
|
| 13 | + | a[..ib.len().min(LEN)].copy_from_slice(&ib[..ib.len().min(LEN)]); |
|
| 14 | + | b[..eb.len().min(LEN)].copy_from_slice(&eb[..eb.len().min(LEN)]); |
|
| 15 | + | let lengths_match = subtle::Choice::from((ib.len() == eb.len()) as u8); |
|
| 16 | + | (lengths_match & a.ct_eq(&b)).into() |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | /// Generate a 32-byte cryptographically random hex token. |
|
| 20 | + | pub fn generate_session_token() -> String { |
|
| 21 | + | let mut bytes = [0u8; 32]; |
|
| 22 | + | rand::rngs::OsRng.fill_bytes(&mut bytes); |
|
| 23 | + | bytes.iter().map(|b| format!("{:02x}", b)).collect() |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | /// Build a session cookie with HttpOnly, SameSite=Strict, 7-day Max-Age. |
|
| 27 | + | pub fn build_session_cookie(token: &str, secure: bool) -> String { |
|
| 28 | + | let mut cookie = format!( |
|
| 29 | + | "session={}; HttpOnly; SameSite=Strict; Path=/; Max-Age=604800", |
|
| 30 | + | token |
|
| 31 | + | ); |
|
| 32 | + | if secure { |
|
| 33 | + | cookie.push_str("; Secure"); |
|
| 34 | + | } |
|
| 35 | + | cookie |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | /// Build a cookie that clears the session. |
|
| 39 | + | pub fn clear_session_cookie() -> String { |
|
| 40 | + | "session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0".to_string() |
|
| 41 | + | } |
|
| 42 | + | ||
| 43 | + | /// Extract the session token from the Cookie header. |
|
| 44 | + | pub fn extract_session_cookie(headers: &axum::http::HeaderMap) -> Option<String> { |
|
| 45 | + | let cookie_header = headers.get("cookie")?.to_str().ok()?; |
|
| 46 | + | for part in cookie_header.split(';') { |
|
| 47 | + | let part = part.trim(); |
|
| 48 | + | if let Some(val) = part.strip_prefix("session=") { |
|
| 49 | + | let val = val.trim().to_string(); |
|
| 50 | + | if !val.is_empty() { |
|
| 51 | + | return Some(val); |
|
| 52 | + | } |
|
| 53 | + | } |
|
| 54 | + | } |
|
| 55 | + | None |
|
| 56 | + | } |
| 1 | + | [workspace] |
|
| 2 | + | members = ["cargo:apps/sipp"] |
|
| 3 | + | ||
| 4 | + | # Config for 'dist' |
|
| 5 | + | [dist] |
|
| 6 | + | # The preferred dist version to use in CI (Cargo.toml SemVer syntax) |
|
| 7 | + | cargo-dist-version = "0.30.4" |
|
| 8 | + | # CI backends to support |
|
| 9 | + | ci = "github" |
|
| 10 | + | # The installers to generate for each app |
|
| 11 | + | installers = ["shell", "homebrew"] |
|
| 12 | + | # A GitHub repo to push Homebrew formulas to |
|
| 13 | + | tap = "stevedylandev/homebrew-tap" |
|
| 14 | + | # Target platforms to build apps for (Rust target-triple syntax) |
|
| 15 | + | targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] |
|
| 16 | + | # Path that installers should place binaries in |
|
| 17 | + | install-path = "CARGO_HOME" |
|
| 18 | + | # Publish jobs to run in CI |
|
| 19 | + | publish-jobs = ["homebrew"] |
|
| 20 | + | # Whether to install an updater program |
|
| 21 | + | install-updater = false |