🕹 Repos Web — простой веб-интерфейс для репозитория игр Nintendo Switch на Rust

В этой статье мы разберём создание простого веб-приложения на 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

⚠️ Юридическая оговорка

Данный проект создан исключительно в образовательных и личных некоммерческих целях.
Автор не является владельцем репозитория, не хранит и не распространяет контент и не призывает к нарушению авторских прав.

Все права на игры принадлежат их законным правообладателям.