commit ca775da1f12b02004d7e96bcf80697379bf045e8 Author: ZeptaLab Bot Date: Sun May 31 20:45:50 2026 +0000 feat: initial commit - Rust + React URL shortener diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..982d700 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,92 @@ +# Gitea Actions CI/CD Pipeline + +name: CI/CD + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Cache cargo registry + uses: actions/cache@v3 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Install frontend dependencies + run: cd frontend && npm ci + + - name: Build frontend + run: cd frontend && npm run build + + - name: Rust fmt check + run: cargo fmt --check + + - name: Rust clippy + run: cargo clippy -- -D warnings + + - name: Rust tests + run: cargo test + + build-and-push: + needs: lint-and-test + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Harbor + uses: docker/login-action@v3 + with: + registry: harbor.k8s.crashpoint.ru + username: ${{ secrets.HARBOR_USER }} + password: ${{ secrets.HARBOR_TOKEN }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: harbor.k8s.crashpoint.ru/hello-web/hello-web + tags: | + type=sha + type=raw,value=latest + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + needs: build-and-push + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + + - name: Deploy to Kubernetes + run: | + sed -i "s|IMAGE_PLACEHOLDER|harbor.k8s.crashpoint.ru/hello-web/hello-web:sha-${{ github.sha }}|g" k8s/deployment.yaml + kubectl apply -f k8s/deployment.yaml + kubectl apply -f k8s/service.yaml + kubectl apply -f k8s/ingress.yaml + kubectl rollout status deployment/hello-web --timeout=120s diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..39545d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Dependencies +target/ +frontend/node_modules/ + +# Build outputs +frontend/dist/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.local diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..562f127 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "hello-web" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = { version = "0.7", features = ["macros"] } +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tower-http = { version = "0.5", features = ["fs", "trace"] } +tracing = "0.1" +tracing-subscriber = "0.3" +uuid = { version = "1", features = ["v4"] } +chrono = { version = "0.4", features = ["serde"] } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..67456ed --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# Build frontend +FROM node:20-alpine AS frontend-build +WORKDIR /app +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ . +RUN npm run build + +# Build Rust backend +FROM rust:1.75 AS rust-builder +WORKDIR /app +COPY Cargo.toml Cargo.lock ./ +COPY src/ ./src/ +RUN cargo build --release +RUN cp target/release/hello-web /app/hello-web + +# Final image +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=rust-builder /app/hello-web /app/hello-web +COPY --from=frontend-build /app/dist /app/frontend/dist +EXPOSE 3000 +CMD ["/app/hello-web"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c3ca7b --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# πŸ”— Hello Web β€” URL Shortener + +Rust + React URL shortener with analytics dashboard. + +## Features + +- Create short URLs with optional custom aliases +- Click tracking and analytics +- Beautiful React frontend with dark theme +- RESTful API backend +- Full CI/CD pipeline with Gitea Actions +- Docker multi-stage builds +- Kubernetes deployment with auto-scaling + +## Tech Stack + +- **Backend**: Rust + Axum +- **Frontend**: React + Vite +- **CI/CD**: Gitea Actions +- **Registry**: Harbor +- **Deployment**: Kubernetes + ArgoCD +- **Monitoring**: VictoriaMetrics + +## Local Development + +```bash +# Backend +cargo run + +# Frontend (separate terminal) +cd frontend +npm install +npm run dev +``` + +## API Endpoints + +- `POST /api/shorten` β€” Create short URL +- `GET /api/urls` β€” List all URLs +- `GET /api/stats` β€” Get analytics +- `GET /api/urls/{id}` β€” Get URL info +- `GET /{id}` β€” Redirect to original URL +- `GET /health` β€” Health check + +## Deployment + +The project uses Gitea Actions CI/CD: + +1. Lint & test on every push +2. Build Docker image on merge to main +3. Push to Harbor registry +4. Deploy to Kubernetes cluster + +## Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ React UI │────▢│ Axum API │────▢│ Harbor β”‚ +β”‚ (Vite) β”‚ β”‚ (Rust) β”‚ β”‚ Registry β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + β–Ό β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Victoria β”‚ β”‚ Kubernetes β”‚ + β”‚ Metrics β”‚ β”‚ Cluster β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..5ce5967 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,44 @@ + + + + + + Hello Web - URL Shortener + + + +
+ + + diff --git a/frontend/main.jsx b/frontend/main.jsx new file mode 100644 index 0000000..2e5947f --- /dev/null +++ b/frontend/main.jsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './styles.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + , +) diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..6579436 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "hello-web-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "axios": "^1.6.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.0", + "vite": "^5.0.0" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..5742c31 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,217 @@ +import React, { useState, useEffect } from 'react' +import axios from 'axios' + +const API_BASE = '/api' + +function App() { + const [url, setUrl] = useState('') + const [alias, setAlias] = useState('') + const [result, setResult] = useState(null) + const [error, setError] = useState('') + const [urls, setUrls] = useState([]) + const [stats, setStats] = useState(null) + const [tab, setTab] = useState('create') + + useEffect(() => { + if (tab === 'list') fetchUrls() + if (tab === 'stats') fetchStats() + }, [tab]) + + const fetchUrls = async () => { + try { + const res = await axios.get(`${API_BASE}/urls`) + setUrls(res.data) + } catch (e) { + console.error('Failed to fetch URLs:', e) + } + } + + const fetchStats = async () => { + try { + const res = await axios.get(`${API_BASE}/stats`) + setStats(res.data) + } catch (e) { + console.error('Failed to fetch stats:', e) + } + } + + const shorten = async (e) => { + e.preventDefault() + setError('') + setResult(null) + + if (!url) { + setError('Please enter a URL') + return + } + + try { + const res = await axios.post(`${API_BASE}/shorten`, { + url, + custom_alias: alias || undefined, + }) + setResult(res.data) + setUrl('') + setAlias('') + fetchUrls() + fetchStats() + } catch (e) { + setError(e.response?.data || 'Failed to shorten URL') + } + } + + const copyToClipboard = (text) => { + navigator.clipboard.writeText(text) + alert('Copied to clipboard!') + } + + return ( +
+

πŸ”— Hello Web

+

URL Shortener built with Rust + React

+ +
+
setTab('create')}> + Create +
+
setTab('list')}> + All URLs +
+
setTab('stats')}> + Stats +
+
+ + {tab === 'create' && ( +
+
+
+ setUrl(e.target.value)} + /> + +
+
+ setAlias(e.target.value)} + style={{ width: '100%' }} + /> +
+
+ + {error &&
❌ {error}
} + + {result && ( +
+

Short URL:

+ + {result.short_url} + +

Original: {result.original_url}

+ +
+ )} +
+ )} + + {tab === 'list' && ( +
+ {urls.length === 0 ? ( +

No URLs created yet

+ ) : ( + + + + + + + + + + + {urls.map((u) => ( + + + + + + + ))} + +
Short URLOriginal URLClicksCreated
+ + {u.id} + + + + {u.original_url} + + {u.click_count}{new Date(u.created_at).toLocaleDateString()}
+ )} +
+ )} + + {tab === 'stats' && stats && ( +
+
+
+
{stats.total_urls}
+
Total URLs
+
+
+
{stats.total_clicks}
+
Total Clicks
+
+
+
+ {stats.total_urls > 0 + ? Math.round(stats.total_clicks / stats.total_urls) + : 0} +
+
Avg Clicks/URL
+
+
+

πŸ† Top URLs

+ {stats.top_urls.length === 0 ? ( +

No data yet

+ ) : ( + + + + + + + + + + {stats.top_urls.map((u) => ( + + + + + + ))} + +
IDClicksOriginal URL
{u.id}{u.click_count} + + {u.original_url.length > 40 + ? u.original_url.slice(0, 40) + '...' + : u.original_url} + +
+ )} +
+ )} +
+ ) +} + +export default App diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..1943cc8 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,9 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + , +) diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..8e6ab31 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 3000, + proxy: { + '/api': 'http://localhost:8080', + }, + }, +}) diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml new file mode 100644 index 0000000..bcc5038 --- /dev/null +++ b/k8s/deployment.yaml @@ -0,0 +1,45 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: hello-web + labels: + app: hello-web +spec: + replicas: 2 + selector: + matchLabels: + app: hello-web + template: + metadata: + labels: + app: hello-web + spec: + containers: + - name: hello-web + image: IMAGE_PLACEHOLDER + ports: + - containerPort: 3000 + name: http + protocol: TCP + env: + - name: RUST_LOG + value: "info" + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + livenessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 10 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 10 diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml new file mode 100644 index 0000000..286b637 --- /dev/null +++ b/k8s/ingress.yaml @@ -0,0 +1,19 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: hello-web + annotations: + nginx.ingress.kubernetes.io/proxy-body-size: "10m" + nginx.ingress.kubernetes.io/use-regex: "true" +spec: + rules: + - host: hello-web.k8s.crashpoint.ru + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: hello-web + port: + number: 80 diff --git a/k8s/service.yaml b/k8s/service.yaml new file mode 100644 index 0000000..7f4c364 --- /dev/null +++ b/k8s/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: hello-web + labels: + app: hello-web +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 3000 + protocol: TCP + name: http + selector: + app: hello-web diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..558e2b8 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,195 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::Json, + routing::{get, post}, + Router, +}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use tokio::sync::RwLock; +use tower_http::trace::TraceLayer; +use tracing::{info, instrument}; + +#[derive(Clone, Serialize, Deserialize, Debug)] +struct ShortUrl { + id: String, + original_url: String, + created_at: String, + click_count: u64, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +struct CreateRequest { + url: String, + custom_alias: Option, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +struct CreateResponse { + short_url: String, + original_url: String, + id: String, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +struct StatsResponse { + total_urls: usize, + total_clicks: u64, + top_urls: Vec, +} + +#[derive(Clone)] +struct AppState { + urls: Arc>>, + base_url: String, +} + +#[instrument(skip(state))] +async fn create_short_url( + State(state): State, + Json(req): Json, +) -> Result, (StatusCode, String)> { + if !req.url.starts_with("http://") && !req.url.starts_with("https://") { + return Err(( + StatusCode::BAD_REQUEST, + "URL must start with http:// or https://".to_string(), + )); + } + + let id = if let Some(alias) = &req.custom_alias { + if alias.len() < 3 || alias.len() > 20 { + return Err(( + StatusCode::BAD_REQUEST, + "Alias must be 3-20 characters".to_string(), + )); + } + alias.clone() + } else { + uuid::Uuid::new_v4().to_string()[..6].to_string() + }; + + let urls = state.urls.read().await; + if urls.contains_key(&id) { + return Err(( + StatusCode::CONFLICT, + format!("Alias '{}' is already taken", id), + )); + } + drop(urls); + + let short_url = format!("{}/{}", state.base_url, id); + let now = chrono::Utc::now().to_rfc3339(); + + let entry = ShortUrl { + id: id.clone(), + original_url: req.url.clone(), + created_at: now.clone(), + click_count: 0, + }; + + state.urls.write().await.insert(id.clone(), entry.clone()); + info!("Created short URL: {} -> {}", id, req.url); + + Ok(Json(CreateResponse { + short_url, + original_url: req.url, + id, + })) +} + +#[instrument(skip(state))] +async fn redirect( + State(state): State, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let urls = state.urls.read().await; + let url = urls.get(&id).cloned(); + drop(urls); + + match url { + Some(mut entry) => { + entry.click_count += 1; + state.urls.write().await.insert(id.clone(), entry.clone()); + info!("Redirect to {}: {} clicks", entry.original_url, entry.click_count); + Ok(axum::http::Response::builder() + .status(StatusCode::FOUND) + .header(axum::http::header::LOCATION, entry.original_url) + .body(axum::body::Body::empty()) + .unwrap()) + } + None => Err((StatusCode::NOT_FOUND, "Short URL not found".to_string())), + } +} + +#[instrument(skip(state))] +async fn list_urls( + State(state): State, +) -> Json> { + let urls = state.urls.read().await; + let mut all_urls: Vec = urls.values().cloned().collect(); + all_urls.sort_by(|a, b| b.click_count.cmp(&a.click_count)); + Json(all_urls) +} + +#[instrument(skip(state))] +async fn get_stats( + State(state): State, +) -> Json { + let urls = state.urls.read().await; + let total_urls = urls.len(); + let total_clicks: u64 = urls.values().map(|u| u.click_count).sum(); + let mut top_urls: Vec = urls.values().cloned().collect(); + top_urls.sort_by(|a, b| b.click_count.cmp(&a.click_count)); + top_urls.truncate(10); + + Json(StatsResponse { + total_urls, + total_clicks, + top_urls, + }) +} + +#[instrument(skip(state))] +async fn get_url_info( + State(state): State, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let urls = state.urls.read().await; + match urls.get(&id) { + Some(url) => Ok(Json(url.clone())), + None => Err((StatusCode::NOT_FOUND, "URL not found".to_string())), + } +} + +async fn health_check() -> &'static str { + "OK" +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .init(); + + let state = AppState { + urls: Arc::new(RwLock::new(HashMap::new())), + base_url: "http://localhost:3000".to_string(), + }; + + let app = Router::new() + .route("/api/shorten", post(create_short_url)) + .route("/api/urls", get(list_urls)) + .route("/api/stats", get(get_stats)) + .route("/api/urls/{id}", get(get_url_info)) + .route("/{id}", get(redirect)) + .route("/health", get(health_check)) + .layer(TraceLayer::new_for_http()); + + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000") + .await + .unwrap(); + info!("Server running on port 3000"); + axum::serve(listener, app).await.unwrap(); +}