mirror of
https://github.com/antonkomarev/github-profile-views-counter.git
synced 2026-07-31 01:18:18 -04:00
Add Rust implementation using shields.rs for badge generation
This adds a complete Rust rewrite of the GitHub Profile Views Counter using the shields.rs crate for badge generation. The implementation includes: - Badge rendering with shields crate (flat, flat-square, plastic, for-the-badge, and pixel styles) - HTTP server using axum framework - File-based storage with file locking - Optional PostgreSQL storage (via postgres feature) - Username validation following GitHub rules - Number formatting with commas and abbreviations (K, M, B, etc.) - Docker support with multi-stage build
This commit is contained in:
14
rust-app/.env.example
Normal file
14
rust-app/.env.example
Normal file
@@ -0,0 +1,14 @@
|
||||
# Server configuration
|
||||
HOST=0.0.0.0
|
||||
PORT=8080
|
||||
|
||||
# Storage configuration
|
||||
# Use "file" for file-based storage or "pdo"/"postgres" for PostgreSQL (requires postgres feature)
|
||||
REPOSITORY=file
|
||||
STORAGE_PATH=./storage
|
||||
|
||||
# PostgreSQL configuration (only used when REPOSITORY=pdo or REPOSITORY=postgres)
|
||||
# DATABASE_URL=postgres://user:password@localhost:5432/github_profile_views
|
||||
|
||||
# Logging level
|
||||
RUST_LOG=info
|
||||
2
rust-app/.gitignore
vendored
Normal file
2
rust-app/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
.env
|
||||
2967
rust-app/Cargo.lock
generated
Normal file
2967
rust-app/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
rust-app/Cargo.toml
Normal file
41
rust-app/Cargo.toml
Normal file
@@ -0,0 +1,41 @@
|
||||
[package]
|
||||
name = "github-profile-views-counter"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "GitHub Profile Views Counter - A badge generation service"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
# Web framework
|
||||
axum = "0.8"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
|
||||
# Badge generation
|
||||
shields = "1"
|
||||
|
||||
# Database
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres"], optional = true }
|
||||
|
||||
# File locking
|
||||
fs2 = "0.4"
|
||||
|
||||
# Environment and configuration
|
||||
dotenvy = "0.15"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# Error handling
|
||||
thiserror = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
postgres = ["sqlx"]
|
||||
53
rust-app/Dockerfile
Normal file
53
rust-app/Dockerfile
Normal file
@@ -0,0 +1,53 @@
|
||||
# Build stage
|
||||
FROM rust:1.83-slim-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy manifests
|
||||
COPY Cargo.toml Cargo.lock* ./
|
||||
|
||||
# Create a dummy main.rs to cache dependencies
|
||||
RUN mkdir src && \
|
||||
echo "fn main() {}" > src/main.rs && \
|
||||
cargo build --release && \
|
||||
rm -rf src
|
||||
|
||||
# Copy source code
|
||||
COPY src ./src
|
||||
|
||||
# Build the application
|
||||
RUN touch src/main.rs && cargo build --release
|
||||
|
||||
# Runtime stage
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create storage directory
|
||||
RUN mkdir -p /app/storage
|
||||
|
||||
# Copy the binary
|
||||
COPY --from=builder /app/target/release/github-profile-views-counter /app/
|
||||
|
||||
# Set environment variables
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=8080
|
||||
ENV STORAGE_PATH=/app/storage
|
||||
ENV RUST_LOG=info
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Run the application
|
||||
CMD ["./github-profile-views-counter"]
|
||||
17
rust-app/docker-compose.yml
Normal file
17
rust-app/docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- HOST=0.0.0.0
|
||||
- PORT=8080
|
||||
- REPOSITORY=file
|
||||
- STORAGE_PATH=/app/storage
|
||||
- RUST_LOG=info
|
||||
volumes:
|
||||
- storage:/app/storage
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
storage:
|
||||
169
rust-app/src/badge.rs
Normal file
169
rust-app/src/badge.rs
Normal file
@@ -0,0 +1,169 @@
|
||||
use shields::{render_badge_svg, BadgeParams, BadgeStyle as ShieldsBadgeStyle};
|
||||
|
||||
const ABBREVIATIONS: [&str; 7] = ["", "K", "M", "B", "T", "Qa", "Qi"];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BadgeStyle {
|
||||
Flat,
|
||||
FlatSquare,
|
||||
Plastic,
|
||||
ForTheBadge,
|
||||
Pixel,
|
||||
}
|
||||
|
||||
impl BadgeStyle {
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"flat" => Some(Self::Flat),
|
||||
"flat-square" => Some(Self::FlatSquare),
|
||||
"plastic" => Some(Self::Plastic),
|
||||
"for-the-badge" => Some(Self::ForTheBadge),
|
||||
"pixel" => Some(Self::Pixel),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_shields_style(self) -> ShieldsBadgeStyle {
|
||||
match self {
|
||||
Self::Flat => ShieldsBadgeStyle::Flat,
|
||||
Self::FlatSquare => ShieldsBadgeStyle::FlatSquare,
|
||||
Self::Plastic => ShieldsBadgeStyle::Plastic,
|
||||
Self::ForTheBadge => ShieldsBadgeStyle::ForTheBadge,
|
||||
Self::Pixel => ShieldsBadgeStyle::Flat, // Not used for pixel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BadgeStyle {
|
||||
fn default() -> Self {
|
||||
Self::Flat
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BadgeRenderer;
|
||||
|
||||
impl BadgeRenderer {
|
||||
pub fn render(
|
||||
label: &str,
|
||||
count: u64,
|
||||
color: &str,
|
||||
style: BadgeStyle,
|
||||
abbreviated: bool,
|
||||
) -> String {
|
||||
if style == BadgeStyle::Pixel {
|
||||
return Self::render_pixel();
|
||||
}
|
||||
|
||||
let message = if abbreviated {
|
||||
Self::format_abbreviated(count)
|
||||
} else {
|
||||
Self::format_with_commas(count)
|
||||
};
|
||||
|
||||
let params = BadgeParams {
|
||||
style: style.to_shields_style(),
|
||||
label: Some(label),
|
||||
message: Some(&message),
|
||||
label_color: None,
|
||||
message_color: Some(color),
|
||||
link: None,
|
||||
extra_link: None,
|
||||
logo: None,
|
||||
logo_color: None,
|
||||
};
|
||||
|
||||
render_badge_svg(¶ms)
|
||||
}
|
||||
|
||||
pub fn render_error(label: &str, message: &str) -> String {
|
||||
let params = BadgeParams {
|
||||
style: ShieldsBadgeStyle::Flat,
|
||||
label: Some(label),
|
||||
message: Some(message),
|
||||
label_color: None,
|
||||
message_color: Some("red"),
|
||||
link: None,
|
||||
extra_link: None,
|
||||
logo: None,
|
||||
logo_color: None,
|
||||
};
|
||||
|
||||
render_badge_svg(¶ms)
|
||||
}
|
||||
|
||||
fn render_pixel() -> String {
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>"#.to_string()
|
||||
}
|
||||
|
||||
fn format_with_commas(number: u64) -> String {
|
||||
let s = number.to_string();
|
||||
let bytes: Vec<_> = s.bytes().rev().collect();
|
||||
let chunks: Vec<String> = bytes
|
||||
.chunks(3)
|
||||
.map(|chunk| String::from_utf8(chunk.iter().copied().collect()).unwrap())
|
||||
.collect();
|
||||
chunks.join(",").chars().rev().collect()
|
||||
}
|
||||
|
||||
fn format_abbreviated(number: u64) -> String {
|
||||
let mut num = number as f64;
|
||||
let mut idx = 0;
|
||||
|
||||
while num >= 1000.0 && idx < ABBREVIATIONS.len() - 1 {
|
||||
num /= 1000.0;
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
if idx == 0 {
|
||||
number.to_string()
|
||||
} else {
|
||||
format!("{:.1}{}", num, ABBREVIATIONS[idx])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_format_with_commas() {
|
||||
assert_eq!(BadgeRenderer::format_with_commas(0), "0");
|
||||
assert_eq!(BadgeRenderer::format_with_commas(999), "999");
|
||||
assert_eq!(BadgeRenderer::format_with_commas(1000), "1,000");
|
||||
assert_eq!(BadgeRenderer::format_with_commas(1234567), "1,234,567");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_abbreviated() {
|
||||
assert_eq!(BadgeRenderer::format_abbreviated(0), "0");
|
||||
assert_eq!(BadgeRenderer::format_abbreviated(999), "999");
|
||||
assert_eq!(BadgeRenderer::format_abbreviated(1000), "1.0K");
|
||||
assert_eq!(BadgeRenderer::format_abbreviated(1500), "1.5K");
|
||||
assert_eq!(BadgeRenderer::format_abbreviated(1000000), "1.0M");
|
||||
assert_eq!(BadgeRenderer::format_abbreviated(1234567890), "1.2B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_badge_style_from_str() {
|
||||
assert_eq!(BadgeStyle::from_str("flat"), Some(BadgeStyle::Flat));
|
||||
assert_eq!(
|
||||
BadgeStyle::from_str("flat-square"),
|
||||
Some(BadgeStyle::FlatSquare)
|
||||
);
|
||||
assert_eq!(BadgeStyle::from_str("plastic"), Some(BadgeStyle::Plastic));
|
||||
assert_eq!(
|
||||
BadgeStyle::from_str("for-the-badge"),
|
||||
Some(BadgeStyle::ForTheBadge)
|
||||
);
|
||||
assert_eq!(BadgeStyle::from_str("pixel"), Some(BadgeStyle::Pixel));
|
||||
assert_eq!(BadgeStyle::from_str("invalid"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_pixel() {
|
||||
let svg = BadgeRenderer::render("test", 0, "blue", BadgeStyle::Pixel, false);
|
||||
assert!(svg.contains("width=\"1\""));
|
||||
assert!(svg.contains("height=\"1\""));
|
||||
}
|
||||
}
|
||||
49
rust-app/src/config.rs
Normal file
49
rust-app/src/config.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum StorageType {
|
||||
File,
|
||||
#[cfg(feature = "postgres")]
|
||||
Postgres,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub storage_type: StorageType,
|
||||
pub storage_path: PathBuf,
|
||||
#[cfg(feature = "postgres")]
|
||||
pub database_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Self {
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
let storage_type = match env::var("REPOSITORY").as_deref() {
|
||||
#[cfg(feature = "postgres")]
|
||||
Ok("pdo") | Ok("postgres") => StorageType::Postgres,
|
||||
_ => StorageType::File,
|
||||
};
|
||||
|
||||
Self {
|
||||
host: env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()),
|
||||
port: env::var("PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(8080),
|
||||
storage_type,
|
||||
storage_path: env::var("STORAGE_PATH")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("./storage")),
|
||||
#[cfg(feature = "postgres")]
|
||||
database_url: env::var("DATABASE_URL").ok(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn address(&self) -> String {
|
||||
format!("{}:{}", self.host, self.port)
|
||||
}
|
||||
}
|
||||
35
rust-app/src/error.rs
Normal file
35
rust-app/src/error.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AppError {
|
||||
#[error("Invalid username: {0}")]
|
||||
InvalidUsername(String),
|
||||
|
||||
#[error("Storage error: {0}")]
|
||||
Storage(String),
|
||||
|
||||
#[error("Invalid parameter: {0}")]
|
||||
InvalidParameter(String),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = match &self {
|
||||
AppError::InvalidUsername(_) => StatusCode::BAD_REQUEST,
|
||||
AppError::InvalidParameter(_) => StatusCode::BAD_REQUEST,
|
||||
AppError::Storage(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AppError::Io(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
(status, self.to_string()).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, AppError>;
|
||||
121
rust-app/src/handler.rs
Normal file
121
rust-app/src/handler.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::{header, HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::badge::{BadgeRenderer, BadgeStyle};
|
||||
use crate::storage::CounterStorage;
|
||||
use crate::username::Username;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BadgeQuery {
|
||||
pub username: String,
|
||||
#[serde(default = "default_color")]
|
||||
pub color: String,
|
||||
#[serde(default)]
|
||||
pub style: Option<String>,
|
||||
#[serde(default = "default_label")]
|
||||
pub label: String,
|
||||
#[serde(default)]
|
||||
pub base: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub abbreviated: bool,
|
||||
}
|
||||
|
||||
fn default_color() -> String {
|
||||
"blue".to_string()
|
||||
}
|
||||
|
||||
fn default_label() -> String {
|
||||
"Profile views".to_string()
|
||||
}
|
||||
|
||||
pub struct SvgResponse(pub String);
|
||||
|
||||
impl IntoResponse for SvgResponse {
|
||||
fn into_response(self) -> Response {
|
||||
(
|
||||
StatusCode::OK,
|
||||
[
|
||||
(header::CONTENT_TYPE, "image/svg+xml"),
|
||||
(header::CACHE_CONTROL, "max-age=0, no-cache, no-store, must-revalidate"),
|
||||
(header::PRAGMA, "no-cache"),
|
||||
(header::EXPIRES, "0"),
|
||||
],
|
||||
self.0,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_github_camo(headers: &HeaderMap) -> bool {
|
||||
headers
|
||||
.get(header::USER_AGENT)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|ua| ua.starts_with("github-camo"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub async fn badge_handler<S: CounterStorage>(
|
||||
State(storage): State<Arc<S>>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<BadgeQuery>,
|
||||
) -> Response {
|
||||
// Validate username
|
||||
let username = match Username::new(&query.username) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
let svg = BadgeRenderer::render_error(&query.label, &e.to_string());
|
||||
return SvgResponse(svg).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Parse style
|
||||
let style = query
|
||||
.style
|
||||
.as_deref()
|
||||
.and_then(BadgeStyle::from_str)
|
||||
.unwrap_or_default();
|
||||
|
||||
// Only increment if request is from GitHub Camo proxy
|
||||
let count = if is_github_camo(&headers) {
|
||||
match storage.increment(&username).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("Storage error: {}", e);
|
||||
let svg = BadgeRenderer::render_error(&query.label, "Storage error");
|
||||
return SvgResponse(svg).into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match storage.get_count(&username).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("Storage error: {}", e);
|
||||
let svg = BadgeRenderer::render_error(&query.label, "Storage error");
|
||||
return SvgResponse(svg).into_response();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add base count if provided
|
||||
let total_count = count + query.base.unwrap_or(0);
|
||||
|
||||
// Render badge
|
||||
let svg = BadgeRenderer::render(
|
||||
&query.label,
|
||||
total_count,
|
||||
&query.color,
|
||||
style,
|
||||
query.abbreviated,
|
||||
);
|
||||
|
||||
SvgResponse(svg).into_response()
|
||||
}
|
||||
|
||||
pub async fn health_handler() -> &'static str {
|
||||
"OK"
|
||||
}
|
||||
65
rust-app/src/main.rs
Normal file
65
rust-app/src/main.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
mod badge;
|
||||
mod config;
|
||||
mod error;
|
||||
mod handler;
|
||||
mod storage;
|
||||
mod username;
|
||||
|
||||
use axum::{routing::get, Router};
|
||||
use std::sync::Arc;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use config::{Config, StorageType};
|
||||
use handler::{badge_handler, health_handler};
|
||||
use storage::{CounterStorage, FileStorage};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Initialize tracing
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
// Load configuration
|
||||
let config = Config::from_env();
|
||||
tracing::info!("Starting server with config: {:?}", config);
|
||||
|
||||
// Create router based on storage type
|
||||
let app = match config.storage_type {
|
||||
StorageType::File => {
|
||||
let storage = FileStorage::new(config.storage_path.clone())?;
|
||||
create_router(storage)
|
||||
}
|
||||
#[cfg(feature = "postgres")]
|
||||
StorageType::Postgres => {
|
||||
let database_url = config
|
||||
.database_url
|
||||
.as_ref()
|
||||
.expect("DATABASE_URL is required for postgres storage");
|
||||
let storage = storage::PostgresStorage::from_url(database_url).await?;
|
||||
storage.run_migrations().await?;
|
||||
create_router(storage)
|
||||
}
|
||||
};
|
||||
|
||||
// Start server
|
||||
let listener = tokio::net::TcpListener::bind(config.address()).await?;
|
||||
tracing::info!("Listening on {}", listener.local_addr()?);
|
||||
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_router<S: CounterStorage + 'static>(storage: S) -> Router {
|
||||
let storage = Arc::new(storage);
|
||||
|
||||
Router::new()
|
||||
.route("/", get(badge_handler::<S>))
|
||||
.route("/health", get(health_handler))
|
||||
.with_state(storage)
|
||||
}
|
||||
134
rust-app/src/storage/file.rs
Normal file
134
rust-app/src/storage/file.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::username::Username;
|
||||
use fs2::FileExt;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct FileStorage {
|
||||
storage_path: PathBuf,
|
||||
}
|
||||
|
||||
impl FileStorage {
|
||||
pub fn new(storage_path: PathBuf) -> Result<Self> {
|
||||
fs::create_dir_all(&storage_path)?;
|
||||
Ok(Self { storage_path })
|
||||
}
|
||||
|
||||
fn count_file_path(&self, username: &Username) -> PathBuf {
|
||||
self.storage_path
|
||||
.join(format!("{}-views-count", username.as_str()))
|
||||
}
|
||||
|
||||
fn log_file_path(&self, username: &Username) -> PathBuf {
|
||||
self.storage_path
|
||||
.join(format!("{}-views", username.as_str()))
|
||||
}
|
||||
|
||||
fn read_count(&self, username: &Username) -> Result<u64> {
|
||||
let path = self.count_file_path(username);
|
||||
|
||||
if !path.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut file = File::open(&path)?;
|
||||
file.lock_exclusive()
|
||||
.map_err(|e| AppError::Storage(format!("Failed to lock file: {}", e)))?;
|
||||
|
||||
let mut contents = String::new();
|
||||
file.read_to_string(&mut contents)?;
|
||||
|
||||
file.unlock()
|
||||
.map_err(|e| AppError::Storage(format!("Failed to unlock file: {}", e)))?;
|
||||
|
||||
contents
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|e| AppError::Storage(format!("Failed to parse count: {}", e)))
|
||||
}
|
||||
|
||||
fn write_count(&self, username: &Username, count: u64) -> Result<()> {
|
||||
let path = self.count_file_path(username);
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(&path)?;
|
||||
|
||||
file.lock_exclusive()
|
||||
.map_err(|e| AppError::Storage(format!("Failed to lock file: {}", e)))?;
|
||||
|
||||
write!(file, "{}", count)?;
|
||||
|
||||
file.unlock()
|
||||
.map_err(|e| AppError::Storage(format!("Failed to unlock file: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_log(&self, username: &Username) -> Result<()> {
|
||||
let path = self.log_file_path(username);
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)?;
|
||||
|
||||
file.lock_exclusive()
|
||||
.map_err(|e| AppError::Storage(format!("Failed to lock file: {}", e)))?;
|
||||
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
writeln!(file, "{}", timestamp)?;
|
||||
|
||||
file.unlock()
|
||||
.map_err(|e| AppError::Storage(format!("Failed to unlock file: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl super::CounterStorage for FileStorage {
|
||||
async fn increment(&self, username: &Username) -> Result<u64> {
|
||||
// Use tokio's spawn_blocking for file I/O
|
||||
let current = self.read_count(username)?;
|
||||
let new_count = current + 1;
|
||||
self.write_count(username, new_count)?;
|
||||
self.append_log(username)?;
|
||||
Ok(new_count)
|
||||
}
|
||||
|
||||
async fn get_count(&self, username: &Username) -> Result<u64> {
|
||||
self.read_count(username)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::storage::CounterStorage;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_increment_and_get() {
|
||||
let dir = tempdir().unwrap();
|
||||
let storage = FileStorage::new(dir.path().to_path_buf()).unwrap();
|
||||
let username = Username::new("testuser").unwrap();
|
||||
|
||||
assert_eq!(storage.get_count(&username).await.unwrap(), 0);
|
||||
|
||||
let count = storage.increment(&username).await.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
let count = storage.increment(&username).await.unwrap();
|
||||
assert_eq!(count, 2);
|
||||
|
||||
assert_eq!(storage.get_count(&username).await.unwrap(), 2);
|
||||
}
|
||||
}
|
||||
16
rust-app/src/storage/mod.rs
Normal file
16
rust-app/src/storage/mod.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
mod file;
|
||||
#[cfg(feature = "postgres")]
|
||||
mod postgres;
|
||||
|
||||
pub use file::FileStorage;
|
||||
#[cfg(feature = "postgres")]
|
||||
pub use postgres::PostgresStorage;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::username::Username;
|
||||
use std::future::Future;
|
||||
|
||||
pub trait CounterStorage: Send + Sync {
|
||||
fn increment(&self, username: &Username) -> impl Future<Output = Result<u64>> + Send;
|
||||
fn get_count(&self, username: &Username) -> impl Future<Output = Result<u64>> + Send;
|
||||
}
|
||||
63
rust-app/src/storage/postgres.rs
Normal file
63
rust-app/src/storage/postgres.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::username::Username;
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub struct PostgresStorage {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresStorage {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
pub async fn from_url(database_url: &str) -> Result<Self> {
|
||||
let pool = PgPool::connect(database_url)
|
||||
.await
|
||||
.map_err(|e| AppError::Storage(format!("Failed to connect to database: {}", e)))?;
|
||||
|
||||
Ok(Self { pool })
|
||||
}
|
||||
|
||||
pub async fn run_migrations(&self) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS github_profile_views (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(39) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_github_profile_views_username
|
||||
ON github_profile_views(username);
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| AppError::Storage(format!("Failed to run migrations: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl super::CounterStorage for PostgresStorage {
|
||||
async fn increment(&self, username: &Username) -> Result<u64> {
|
||||
sqlx::query("INSERT INTO github_profile_views (username) VALUES ($1)")
|
||||
.bind(username.as_str())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| AppError::Storage(format!("Failed to insert view: {}", e)))?;
|
||||
|
||||
self.get_count(username).await
|
||||
}
|
||||
|
||||
async fn get_count(&self, username: &Username) -> Result<u64> {
|
||||
let row: (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM github_profile_views WHERE username = $1")
|
||||
.bind(username.as_str())
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| AppError::Storage(format!("Failed to get count: {}", e)))?;
|
||||
|
||||
Ok(row.0 as u64)
|
||||
}
|
||||
}
|
||||
99
rust-app/src/username.rs
Normal file
99
rust-app/src/username.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
use crate::error::{AppError, Result};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Username(String);
|
||||
|
||||
impl Username {
|
||||
pub fn new(value: &str) -> Result<Self> {
|
||||
let trimmed = value.trim();
|
||||
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::InvalidUsername("Username cannot be empty".into()));
|
||||
}
|
||||
|
||||
if trimmed.len() > 39 {
|
||||
return Err(AppError::InvalidUsername(
|
||||
"Username must be 39 characters or less".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if !Self::is_valid_username(trimmed) {
|
||||
return Err(AppError::InvalidUsername(
|
||||
"Username may only contain alphanumeric characters or single hyphens, \
|
||||
and cannot begin or end with a hyphen"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Self(trimmed.to_lowercase()))
|
||||
}
|
||||
|
||||
fn is_valid_username(s: &str) -> bool {
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
|
||||
// Must start with alphanumeric
|
||||
if !chars.first().is_some_and(|c| c.is_ascii_alphanumeric()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must end with alphanumeric
|
||||
if !chars.last().is_some_and(|c| c.is_ascii_alphanumeric()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check all characters and no consecutive hyphens
|
||||
let mut prev_hyphen = false;
|
||||
for c in &chars {
|
||||
if *c == '-' {
|
||||
if prev_hyphen {
|
||||
return false; // Consecutive hyphens not allowed
|
||||
}
|
||||
prev_hyphen = true;
|
||||
} else if c.is_ascii_alphanumeric() {
|
||||
prev_hyphen = false;
|
||||
} else {
|
||||
return false; // Invalid character
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Username {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_usernames() {
|
||||
assert!(Username::new("antonkomarev").is_ok());
|
||||
assert!(Username::new("user-name").is_ok());
|
||||
assert!(Username::new("user123").is_ok());
|
||||
assert!(Username::new("a").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_usernames() {
|
||||
assert!(Username::new("").is_err());
|
||||
assert!(Username::new("-username").is_err());
|
||||
assert!(Username::new("username-").is_err());
|
||||
assert!(Username::new("user--name").is_err());
|
||||
assert!(Username::new(&"a".repeat(40)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lowercase_normalization() {
|
||||
let username = Username::new("AntonKomarev").unwrap();
|
||||
assert_eq!(username.as_str(), "antonkomarev");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user