В этой статье мы разберём создание простого веб-приложения на Rust, которое отображает репозиторий игр для Nintendo Switch в виде удобного каталога с обложками, поиском и magnet-ссылками.
Проект предназначен исключительно для личного, некоммерческого использования — как технический эксперимент и пример работы с Rust, Axum и JSON-API.
⚠️ Важно:
Репозиторий с играми не принадлежит автору статьи.
Источник данных:
https://repos-app.ru/nx.jsonАвтор проекта не хранит и не распространяет игровые файлы, а лишь отображает публично доступную информацию.
📌 Что умеет Repos Web
- Загружает JSON-репозиторий с играми
- Кэширует данные локально
- Показывает:
- обложку
- название
- размер
- дату публикации
- magnet-ссылку
- Поддерживает поиск по названию
- Работает как systemd-сервис на VPS
⚙️ Подготовка VPS и установка Rust
Обновляем систему и ставим необходимые пакеты:
sudo apt update
sudo apt install -y curl build-essential
Устанавливаем Rust через rustup:
curl https://sh.rustup.rs -sSf | sh
source ~/.cargo/env
rustup default stable
Установка CA-сертификатов (Ubuntu 24.04)
apt install -y ca-certificates
update-ca-certificates
Проверка установки Rust:
rustc --version
📁 Создание проекта
mkdir repos-web
cd repos-web
cargo init --bin
📦 Cargo.toml
[package]
name = "repos-web"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tower-http = { version = "0.5", features = ["fs"] }
askama = "0.12"
chrono = "0.4"
🗂 Структура каталогов
mkdir -p src templates static data
🧩 Модели данных — src/models.rs
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct Repo {
pub name: String,
pub version: String,
pub items: Vec<Game>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Game {
pub title: String,
pub hash: String,
pub tracker: String,
pub poster: String,
pub size: u64,
pub published_date: i64,
}
🌐 Загрузка и кэширование репозитория — src/repo.rs
use crate::models::Repo;
use std::{fs, path::Path};
const CACHE: &str = "data/nx.json";
const REMOTE: &str = "https://repos-app.ru/nx.json";
pub async fn load_repo() -> Repo {
if Path::new(CACHE).exists() {
let data = fs::read_to_string(CACHE).expect("failed to read cache");
serde_json::from_str(&data).expect("invalid cached json")
} else {
update_repo().await
}
}
pub async fn update_repo() -> Repo {
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.build()
.expect("failed to build reqwest client");
let text = client
.get(REMOTE)
.send()
.await
.expect("request failed")
.text()
.await
.expect("failed to read body");
fs::create_dir_all("data").expect("failed to create data dir");
fs::write(CACHE, &text).expect("failed to write cache");
serde_json::from_str(&text).expect("invalid remote json")
}
🚀 Основное приложение — src/main.rs
mod models;
mod repo;
use askama::Template;
use axum::{
extract::Query,
response::Html,
routing::get,
Router,
};
use models::{Game, Repo};
use repo::load_repo;
use std::sync::Arc;
use tokio::sync::RwLock;
use tower_http::services::ServeDir;
#[derive(Template)]
#[template(path = "index.html")]
struct IndexTemplate {
games: Vec<ViewGame>,
}
#[derive(Clone)]
struct AppState {
repo: Arc<RwLock<Repo>>,
}
#[derive(Clone)]
struct ViewGame {
title: String,
poster: String,
size: String,
date: String,
magnet: String,
}
#[derive(serde::Deserialize)]
struct Search {
q: Option<String>,
}
#[tokio::main]
async fn main() {
let repo = load_repo().await;
let state = AppState {
repo: Arc::new(RwLock::new(repo)),
};
let app = Router::new()
.route("/", get(index))
.with_state(state)
.nest_service("/static", ServeDir::new("static"));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn index(
Query(search): Query<Search>,
axum::extract::State(state): axum::extract::State<AppState>,
) -> Html<String> {
let repo = state.repo.read().await;
let mut items: Vec<Game> = repo.items.clone();
if let Some(q) = search.q {
let q = q.to_lowercase();
items.retain(|g| g.title.to_lowercase().contains(&q));
}
let games = items
.into_iter()
.map(|g| ViewGame {
title: g.title,
poster: g.poster,
size: format!("{:.2} GB", g.size as f64 / 1024.0 / 1024.0 / 1024.0),
date: chrono::NaiveDateTime::from_timestamp_opt(g.published_date, 0)
.unwrap()
.date()
.to_string(),
magnet: format!(
"magnet:?xt=urn:btih:{}&tr={}",
g.hash, g.tracker
),
})
.collect();
let tpl = IndexTemplate { games };
Html(tpl.render().unwrap())
}
🖼 HTML-шаблон — templates/index.html
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Repos Web</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header>
<h1>Repos Web</h1>
<form>
<input type="text" name="q" placeholder="Поиск игры">
</form>
</header>
<main>
{% for g in games %}
<div class="card">
<img src="{{ g.poster }}" loading="lazy">
<h3>{{ g.title }}</h3>
<p>{{ g.size }} | {{ g.date }}</p>
<a href="{{ g.magnet }}">Magnet</a>
</div>
{% endfor %}
</main>
</body>
</html>
🎨 Стили — static/style.css
body {
background: #0f172a;
color: #e5e7eb;
font-family: system-ui;
}
header {
padding: 20px;
text-align: center;
}
input {
padding: 10px;
width: 300px;
}
main {
display: grid;
grid-template-columns: repeat(auto-fill, 200px);
gap: 16px;
justify-content: center;
}
.card {
background: #020617;
padding: 10px;
border-radius: 8px;
}
.card img {
width: 100%;
border-radius: 6px;
}
a {
display: block;
text-align: center;
background: #2563eb;
color: white;
padding: 6px;
margin-top: 8px;
text-decoration: none;
}
🔨 Сборка и запуск
cargo build --release
Запуск:
./target/release/repos-web
Открываем в браузере:
http://IP_VPS:3000
🎉 Repos Web работает

🔁 systemd-сервис
sudo nano /etc/systemd/system/repos-web.service
[Unit]
Description=Repos Web
After=network.target
[Service]
ExecStart=/opt/repos-web/repos-web
WorkingDirectory=/opt/repos-web
Restart=always
User=root
[Install]
WantedBy=multi-user.target
Установка файлов и запуск:
sudo mkdir -p /opt/repos-web
sudo cp target/release/repos-web /opt/repos-web/
sudo cp -r static templates data /opt/repos-web/
sudo systemctl daemon-reexec
sudo systemctl enable --now repos-web
⚠️ Юридическая оговорка
Данный проект создан исключительно в образовательных и личных некоммерческих целях.
Автор не является владельцем репозитория, не хранит и не распространяет контент и не призывает к нарушению авторских прав.
Все права на игры принадлежат их законным правообладателям.