feat: init a10f7095
Steve · 2026-04-01 19:49 175 file(s) · +10171 −0
.gitignore (added) +5 −0
1 +
/target
2 +
*.sqlite
3 +
*.db
4 +
.env
5 +
.DS_Store
Cargo.toml (added) +44 −0
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" }
apps/feeds/Cargo.toml (added) +22 −0
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"
apps/feeds/Dockerfile (added) +36 −0
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"]
apps/feeds/LICENSE (added) +22 −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.
22 +
apps/feeds/README.md (added) +219 −0
1 +
# Feeds
2 +
3 +
![cover](https://feeds.stevedylan.dev/assets/og.png)
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
apps/feeds/askama.toml (added) +2 −0
1 +
[general]
2 +
dirs = ["src/templates"]
apps/feeds/assets/android-chrome-192x192.png (added) +0 −0

Binary file — no preview.

apps/feeds/assets/android-chrome-512x512.png (added) +0 −0

Binary file — no preview.

apps/feeds/assets/apple-touch-icon.png (added) +0 −0

Binary file — no preview.

apps/feeds/assets/favicon-16x16.png (added) +0 −0

Binary file — no preview.

apps/feeds/assets/favicon-32x32.png (added) +0 −0

Binary file — no preview.

apps/feeds/assets/favicon.ico (added) +0 −0

Binary file — no preview.

apps/feeds/assets/fonts/CommitMono-400-Italic.otf (added) +0 −0

Binary file — no preview.

apps/feeds/assets/fonts/CommitMono-400-Regular.otf (added) +0 −0

Binary file — no preview.

apps/feeds/assets/fonts/CommitMono-700-Italic.otf (added) +0 −0

Binary file — no preview.

apps/feeds/assets/fonts/CommitMono-700-Regular.otf (added) +0 −0

Binary file — no preview.

apps/feeds/assets/icon.png (added) +0 −0

Binary file — no preview.

apps/feeds/assets/og.png (added) +0 −0

Binary file — no preview.

apps/feeds/assets/site.webmanifest (added) +1 −0
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"}
apps/feeds/assets/styles.css (added) +247 −0
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 +
}
apps/feeds/docker-compose.yml (added) +15 −0
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:
apps/feeds/src/auth.rs (added) +69 −0
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 +
}
apps/feeds/src/feeds.rs (added) +353 −0
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 +
}
apps/feeds/src/main.rs (added) +355 −0
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('&', "&amp;")
184 +
        .replace('<', "&lt;")
185 +
        .replace('>', "&gt;")
186 +
        .replace('"', "&quot;")
187 +
        .replace('\'', "&apos;")
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 +
}
apps/feeds/src/models.rs (added) +58 −0
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 +
}
apps/feeds/src/templates/admin.html (added) +55 −0
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>
apps/feeds/src/templates/index.html (added) +70 −0
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>
apps/feeds/src/templates/login.html (added) +28 −0
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>
apps/jotts/.env.example (added) +5 −0
1 +
JOTTS_PASSWORD=changeme
2 +
JOTTS_DB_PATH=jotts.sqlite
3 +
COOKIE_SECURE=false
4 +
HOST=127.0.0.1
5 +
PORT=3000
apps/jotts/Cargo.toml (added) +22 −0
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"
apps/jotts/Dockerfile (added) +38 −0
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"]
apps/jotts/LICENSE (added) +22 −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.
22 +
apps/jotts/README.md (added) +84 −0
1 +
# Jotts
2 +
3 +
![cover](https://files.stevedylan.dev/jotts-demo.png)
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)
apps/jotts/assets/fonts/CommitMono-400-Regular.otf (added) +0 −0

Binary file — no preview.

apps/jotts/assets/fonts/CommitMono-700-Regular.otf (added) +0 −0

Binary file — no preview.

apps/jotts/docker-compose.yml (added) +19 −0
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:
apps/jotts/src/auth.rs (added) +75 −0
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 +
}
apps/jotts/src/db.rs (added) +214 −0
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 +
}
apps/jotts/src/main.rs (added) +14 −0
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 +
}
apps/jotts/src/server.rs (added) +409 −0
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(&note.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 +
}
apps/jotts/static/android-chrome-192x192.png (added) +0 −0

Binary file — no preview.

apps/jotts/static/android-chrome-512x512.png (added) +0 −0

Binary file — no preview.

apps/jotts/static/apple-touch-icon.png (added) +0 −0

Binary file — no preview.

apps/jotts/static/favicon-16x16.png (added) +0 −0

Binary file — no preview.

apps/jotts/static/favicon-32x32.png (added) +0 −0

Binary file — no preview.

apps/jotts/static/favicon.ico (added) +0 −0

Binary file — no preview.

apps/jotts/static/icon.png (added) +0 −0

Binary file — no preview.

apps/jotts/static/og.png (added) +0 −0

Binary file — no preview.

apps/jotts/static/site.webmanifest (added) +1 −0
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"}
apps/jotts/static/styles.css (added) +354 −0
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 +
}
apps/jotts/templates/base.html (added) +37 −0
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>
apps/jotts/templates/edit.html (added) +14 −0
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 %}
apps/jotts/templates/index.html (added) +15 −0
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 %}
apps/jotts/templates/login.html (added) +33 −0
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>
apps/jotts/templates/new.html (added) +14 −0
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 %}
apps/jotts/templates/view.html (added) +17 −0
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 %}
apps/og/.env.example (added) +1 −0
1 +
PORT=3000
apps/og/Cargo.toml (added) +19 −0
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"
apps/og/Dockerfile (added) +35 −0
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"]
apps/og/LICENSE (added) +22 −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.
22 +
apps/og/README.md (added) +23 −0
1 +
# OG
2 +
3 +
![cover](https://files.stevedylan.dev/og-demo-1.png)
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)
apps/og/docker-compose.yml (added) +14 −0
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:
apps/og/src/main.rs (added) +9 −0
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 +
}
apps/og/src/og.rs (added) +129 −0
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 +
}
apps/og/src/server.rs (added) +150 −0
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 +
}
apps/og/static/android-chrome-192x192.png (added) +0 −0

Binary file — no preview.

apps/og/static/android-chrome-512x512.png (added) +0 −0

Binary file — no preview.

apps/og/static/apple-touch-icon.png (added) +0 −0

Binary file — no preview.

apps/og/static/assets/fonts/.gitkeep (added) +0 −0

Binary file — no preview.

apps/og/static/assets/fonts/CommitMono-400-Regular.otf (added) +0 −0

Binary file — no preview.

apps/og/static/assets/fonts/CommitMono-700-Regular.otf (added) +0 −0

Binary file — no preview.

apps/og/static/favicon-16x16.png (added) +0 −0

Binary file — no preview.

apps/og/static/favicon-32x32.png (added) +0 −0

Binary file — no preview.

apps/og/static/favicon.ico (added) +0 −0

Binary file — no preview.

apps/og/static/fonts/CommitMono-400-Italic.otf (added) +0 −0

Binary file — no preview.

apps/og/static/fonts/CommitMono-400-Regular.otf (added) +0 −0

Binary file — no preview.

apps/og/static/fonts/CommitMono-700-Italic.otf (added) +0 −0

Binary file — no preview.

apps/og/static/fonts/CommitMono-700-Regular.otf (added) +0 −0

Binary file — no preview.

apps/og/static/icon.png (added) +0 −0

Binary file — no preview.

apps/og/static/og.png (added) +0 −0

Binary file — no preview.

apps/og/static/site.webmanifest (added) +1 −0
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"}
apps/og/static/styles.css (added) +295 −0
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 +
}
apps/og/templates/base.html (added) +25 −0
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>
apps/og/templates/index.html (added) +19 −0
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 %}
apps/og/templates/results.html (added) +88 −0
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 %}
apps/parcels/.env.example (added) +6 −0
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
apps/parcels/Cargo.toml (added) +25 −0
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"
apps/parcels/Dockerfile (added) +38 −0
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"]
apps/parcels/LICENSE (added) +22 −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.
22 +
apps/parcels/README.md (added) +87 −0
1 +
# Parcels
2 +
3 +
![cover](https://files.stevedylan.dev/parcels-demo.png)
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)
apps/parcels/docker-compose.yml (added) +15 −0
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:
apps/parcels/src/auth.rs (added) +101 −0
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 +
}
apps/parcels/src/db.rs (added) +291 −0
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 +
}
apps/parcels/src/main.rs (added) +386 −0
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 +
}
apps/parcels/src/usps.rs (added) +225 −0
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(&params)
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 +
}
apps/parcels/static/android-chrome-192x192.png (added) +0 −0

Binary file — no preview.

apps/parcels/static/android-chrome-512x512.png (added) +0 −0

Binary file — no preview.

apps/parcels/static/apple-touch-icon.png (added) +0 −0

Binary file — no preview.

apps/parcels/static/assets/fonts/.gitkeep (added) +0 −0

Binary file — no preview.

apps/parcels/static/assets/fonts/CommitMono-400-Regular.otf (added) +0 −0

Binary file — no preview.

apps/parcels/static/assets/fonts/CommitMono-700-Regular.otf (added) +0 −0

Binary file — no preview.

apps/parcels/static/favicon-16x16.png (added) +0 −0

Binary file — no preview.

apps/parcels/static/favicon-32x32.png (added) +0 −0

Binary file — no preview.

apps/parcels/static/favicon.ico (added) +0 −0

Binary file — no preview.

apps/parcels/static/icon.png (added) +0 −0

Binary file — no preview.

apps/parcels/static/og.png (added) +0 −0

Binary file — no preview.

apps/parcels/static/site.webmanifest (added) +1 −0
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"}
apps/parcels/templates/add.html (added) +22 −0
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 %}
apps/parcels/templates/base.html (added) +106 −0
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>
apps/parcels/templates/detail.html (added) +71 −0
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 %}
apps/parcels/templates/index.html (added) +36 −0
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 %}
apps/parcels/templates/login.html (added) +17 −0
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 %}
apps/shrink/Cargo.toml (added) +14 −0
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"
apps/shrink/Dockerfile (added) +36 −0
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"]
apps/shrink/LICENSE (added) +22 −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.
22 +
apps/shrink/README.md (added) +68 −0
1 +
# Shrink
2 +
3 +
![cover](https://files.stevedylan.dev/shrink-demo.png)
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)
apps/shrink/docker-compose.yml (added) +11 −0
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
apps/shrink/src/main.rs (added) +12 −0
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 +
}
apps/shrink/src/server.rs (added) +120 −0
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 +
}
apps/shrink/static/android-chrome-192x192.png (added) +0 −0

Binary file — no preview.

apps/shrink/static/android-chrome-512x512.png (added) +0 −0

Binary file — no preview.

apps/shrink/static/apple-touch-icon.png (added) +0 −0

Binary file — no preview.

apps/shrink/static/favicon-16x16.png (added) +0 −0

Binary file — no preview.

apps/shrink/static/favicon-32x32.png (added) +0 −0

Binary file — no preview.

apps/shrink/static/favicon.ico (added) +0 −0

Binary file — no preview.

apps/shrink/static/fonts/CommitMono-400-Italic.otf (added) +0 −0

Binary file — no preview.

apps/shrink/static/fonts/CommitMono-400-Regular.otf (added) +0 −0

Binary file — no preview.

apps/shrink/static/fonts/CommitMono-700-Italic.otf (added) +0 −0

Binary file — no preview.

apps/shrink/static/fonts/CommitMono-700-Regular.otf (added) +0 −0

Binary file — no preview.

apps/shrink/static/icon.png (added) +0 −0

Binary file — no preview.

apps/shrink/static/og.png (added) +0 −0

Binary file — no preview.

apps/shrink/static/site.webmanifest (added) +1 −0
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"}
apps/shrink/static/styles.css (added) +294 −0
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 +
}
apps/shrink/templates/base.html (added) +27 −0
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>
apps/shrink/templates/index.html (added) +171 −0
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 %}
apps/sipp/.env.example (added) +4 −0
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
apps/sipp/CHANGELOG.md (added) +94 −0
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 +
apps/sipp/Cargo.toml (added) +41 −0
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"
apps/sipp/Dockerfile (added) +36 −0
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"]
apps/sipp/LICENSE (added) +21 −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.
apps/sipp/README.md (added) +244 −0
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
apps/sipp/TODO.md (added) +16 −0
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
apps/sipp/assets/android-chrome-192x192.png (added) +0 −0

Binary file — no preview.

apps/sipp/assets/android-chrome-512x512.png (added) +0 −0

Binary file — no preview.

apps/sipp/assets/apple-touch-icon.png (added) +0 −0

Binary file — no preview.

apps/sipp/assets/favicon-16x16.png (added) +0 −0

Binary file — no preview.

apps/sipp/assets/favicon-32x32.png (added) +0 −0

Binary file — no preview.

apps/sipp/assets/favicon.ico (added) +0 −0

Binary file — no preview.

apps/sipp/assets/fonts/CommitMono-400-Italic.otf (added) +0 −0

Binary file — no preview.

apps/sipp/assets/fonts/CommitMono-400-Regular.otf (added) +0 −0

Binary file — no preview.

apps/sipp/assets/fonts/CommitMono-700-Italic.otf (added) +0 −0

Binary file — no preview.

apps/sipp/assets/fonts/CommitMono-700-Regular.otf (added) +0 −0

Binary file — no preview.

apps/sipp/assets/icon.png (added) +0 −0

Binary file — no preview.

apps/sipp/assets/og.png (added) +0 −0

Binary file — no preview.

apps/sipp/assets/site.webmanifest (added) +1 −0
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"}
apps/sipp/cliff.toml (added) +49 −0
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"
apps/sipp/docker-compose.yml (added) +16 −0
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:
apps/sipp/src/ansi.tmTheme (added) +430 −0
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>
apps/sipp/src/backend.rs (added) +165 −0
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 +
}
apps/sipp/src/config.rs (added) +31 −0
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 +
}
apps/sipp/src/darkmatter.tmTheme (added) +560 −0
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>
apps/sipp/src/db.rs (added) +154 −0
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 +
}
apps/sipp/src/highlight.rs (added) +41 −0
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('&', "&amp;")
36 +
                    .replace('<', "&lt;")
37 +
                    .replace('>', "&gt;");
38 +
                format!("<pre>{}</pre>", escaped)
39 +
            })
40 +
    }
41 +
}
apps/sipp/src/lib.rs (added) +6 −0
1 +
pub mod backend;
2 +
pub mod config;
3 +
pub mod db;
4 +
pub mod highlight;
5 +
pub mod server;
6 +
pub mod tui;
apps/sipp/src/main.rs (added) +73 −0
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 +
}
apps/sipp/src/server.rs (added) +405 −0
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 +
}
apps/sipp/src/tui.rs (added) +1200 −0
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 +
}
apps/sipp/static/styles.css (added) +167 −0
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 +
}
apps/sipp/templates/admin.html (added) +106 −0
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>
apps/sipp/templates/index.html (added) +66 −0
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>
apps/sipp/templates/snippet.html (added) +116 −0
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>
crates/auth/Cargo.toml (added) +9 −0
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 }
crates/auth/src/lib.rs (added) +56 −0
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 +
}
dist-workspace.toml (added) +21 −0
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