irongit

Git hosting and a container registry in one Rust binary (axum + Astro)

irongit/backend/src/models.rs
255 lines8.8 KBRust
1//! Rows shared across modules, and the lookups everything needs.
2
3use std::path::PathBuf;
4
5use chrono::{DateTime, Utc};
6use serde::Serialize;
7use sqlx::{FromRow, PgPool};
8
9use crate::config::Config;
10
11#[derive(Debug, Clone, FromRow, Serialize)]
12pub struct Account {
13 pub id: i64,
14 pub kind: String,
15 pub name: String,
16 pub display_name: String,
17 pub bio: String,
18 pub location: String,
19 pub website: String,
20 pub avatar_key: Option<String>,
21 pub quota_bytes: Option<i64>,
22 pub created_at: DateTime<Utc>,
23 pub updated_at: DateTime<Utc>,
24}
25
26/// Column list for `Account`, as a macro so queries stay `&'static str`
27/// (sqlx refuses runtime-built SQL): `concat!("select ", account_columns!(), " from ...")`.
28#[macro_export]
29macro_rules! account_columns {
30 () => {
31 "id, kind, name, display_name, bio, location, website, avatar_key, quota_bytes, created_at, updated_at"
32 };
33}
34
35impl Account {
36 pub async fn by_name(db: &PgPool, name: &str) -> sqlx::Result<Option<Self>> {
37 sqlx::query_as(concat!("select ", account_columns!(), " from accounts where name = $1"))
38 .bind(name)
39 .fetch_optional(db)
40 .await
41 }
42
43 pub async fn by_id(db: &PgPool, id: i64) -> sqlx::Result<Option<Self>> {
44 sqlx::query_as(concat!("select ", account_columns!(), " from accounts where id = $1"))
45 .bind(id)
46 .fetch_optional(db)
47 .await
48 }
49
50 pub fn is_org(&self) -> bool {
51 self.kind == "org"
52 }
53
54 /// Display name, falling back to the handle.
55 pub fn label(&self) -> &str {
56 if self.display_name.trim().is_empty() { &self.name } else { &self.display_name }
57 }
58
59 pub fn quota(&self, config: &Config) -> u64 {
60 self.quota_bytes.map(|q| q.max(0) as u64).unwrap_or(config.limits.account_quota_bytes)
61 }
62}
63
64#[derive(Debug, Clone, FromRow, Serialize)]
65pub struct Repo {
66 pub id: i64,
67 pub owner_id: i64,
68 pub owner_name: String,
69 pub owner_kind: String,
70 pub name: String,
71 pub description: String,
72 pub visibility: String,
73 pub default_branch: String,
74 pub size_bytes: i64,
75 pub is_empty: bool,
76 pub archived: bool,
77 pub created_at: DateTime<Utc>,
78 pub updated_at: DateTime<Utc>,
79 pub pushed_at: Option<DateTime<Utc>>,
80 /// Largest language on the default branch (see `languages`).
81 pub primary_language: Option<String>,
82}
83
84/// `select ... from repos r join accounts a` yielding `Repo` rows; append
85/// conditions with `concat!(repo_select!(), " where ...")`.
86#[macro_export]
87macro_rules! repo_select {
88 () => {
89 "select r.id, r.owner_id, a.name as owner_name, a.kind as owner_kind, r.name, r.description, r.visibility, \
90 r.default_branch, r.size_bytes, r.is_empty, r.archived, r.created_at, r.updated_at, r.pushed_at, r.primary_language \
91 from repos r join accounts a on a.id = r.owner_id"
92 };
93}
94
95impl Repo {
96 /// Looks a repo up by URL path segments. A trailing ".git" is ignored.
97 pub async fn by_path(db: &PgPool, owner: &str, name: &str) -> sqlx::Result<Option<Self>> {
98 let name = name.strip_suffix(".git").unwrap_or(name);
99 sqlx::query_as(concat!(repo_select!(), " where a.name = $1 and r.name = $2"))
100 .bind(owner)
101 .bind(name)
102 .fetch_optional(db)
103 .await
104 }
105
106 pub async fn by_id(db: &PgPool, id: i64) -> sqlx::Result<Option<Self>> {
107 sqlx::query_as(concat!(repo_select!(), " where r.id = $1"))
108 .bind(id)
109 .fetch_optional(db)
110 .await
111 }
112
113 pub fn is_public(&self) -> bool {
114 self.visibility == "public"
115 }
116
117 pub fn full_name(&self) -> String {
118 format!("{}/{}", self.owner_name, self.name)
119 }
120
121 pub fn url(&self) -> String {
122 format!("/{}/{}", self.owner_name, self.name)
123 }
124
125 /// Repos are stored by id, so renames and transfers never move files.
126 pub fn disk_path(&self, config: &Config) -> PathBuf {
127 repo_disk_path(config, self.id)
128 }
129}
130
131pub fn repo_disk_path(config: &Config, id: i64) -> PathBuf {
132 config.repos_dir().join(format!("{id}.git"))
133}
134
135#[derive(Debug, Clone, FromRow, Serialize)]
136pub struct Package {
137 pub id: i64,
138 pub owner_id: i64,
139 pub owner_name: String,
140 pub owner_kind: String,
141 pub name: String,
142 pub visibility: String,
143 pub repo_id: Option<i64>,
144 pub description: String,
145 pub pull_count: i64,
146 pub created_at: DateTime<Utc>,
147 pub updated_at: DateTime<Utc>,
148}
149
150/// `select ... from packages p join accounts a` yielding `Package` rows.
151#[macro_export]
152macro_rules! package_select {
153 () => {
154 "select p.id, p.owner_id, a.name as owner_name, a.kind as owner_kind, p.name, p.visibility, p.repo_id, \
155 p.description, p.pull_count, p.created_at, p.updated_at from packages p join accounts a on a.id = p.owner_id"
156 };
157}
158
159impl Package {
160 pub async fn by_path(db: &PgPool, owner: &str, name: &str) -> sqlx::Result<Option<Self>> {
161 sqlx::query_as(concat!(package_select!(), " where a.name = $1 and p.name = $2"))
162 .bind(owner)
163 .bind(name.to_ascii_lowercase())
164 .fetch_optional(db)
165 .await
166 }
167
168 pub async fn by_id(db: &PgPool, id: i64) -> sqlx::Result<Option<Self>> {
169 sqlx::query_as(concat!(package_select!(), " where p.id = $1"))
170 .bind(id)
171 .fetch_optional(db)
172 .await
173 }
174
175 pub fn is_public(&self) -> bool {
176 self.visibility == "public"
177 }
178
179 /// "owner/name", the image path after the registry host.
180 pub fn full_name(&self) -> String {
181 format!("{}/{}", self.owner_name, self.name)
182 }
183
184 pub fn url(&self) -> String {
185 format!("/{}/-/packages/{}", self.owner_name, self.name)
186 }
187}
188
189/// Records something an administrator may want to trace later.
190pub async fn audit(db: &PgPool, actor: Option<i64>, action: &str, target: &str, meta: serde_json::Value, ip: Option<&str>) {
191 let result = sqlx::query("insert into audit_log (actor_id, action, target, meta, ip) values ($1, $2, $3, $4, $5)")
192 .bind(actor)
193 .bind(action)
194 .bind(target)
195 .bind(meta)
196 .bind(ip)
197 .execute(db)
198 .await;
199 if let Err(error) = result {
200 tracing::warn!(%error, action, "could not write audit log");
201 }
202}
203
204/// Names: lowercase letters, digits and single hyphens, 1 to 39 characters,
205/// so they work in URLs, SSH paths and Docker image names unchanged.
206pub fn valid_account_name(name: &str) -> Result<(), &'static str> {
207 if name.is_empty() || name.len() > 39 {
208 return Err("Names must be 1 to 39 characters.");
209 }
210 if !name.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') {
211 return Err("Names may only contain lowercase letters, digits and hyphens.");
212 }
213 if name.starts_with('-') || name.ends_with('-') || name.contains("--") {
214 return Err("Names cannot start or end with a hyphen or contain two in a row.");
215 }
216 if RESERVED_NAMES.contains(&name) {
217 return Err("That name is reserved.");
218 }
219 Ok(())
220}
221
222/// Top-level paths the site uses itself.
223pub const RESERVED_NAMES: &[&str] = &[
224 "about", "admin", "api", "assets", "avatars", "dashboard", "dev-ws", "docs", "download", "explore", "help",
225 "install", "join", "login", "logout", "new", "notifications", "organizations", "orgs", "register", "repos",
226 "search", "settings", "signup", "site", "static", "status", "support", "users", "v2", "www", "irongit",
227 "root", "git", "ssh", "lfs", "registry", "security", "terms", "privacy", "favicon", "robots", "sitemap",
228];
229
230/// Repo names: letters, digits, '.', '_' and '-', up to 100 characters.
231pub fn valid_repo_name(name: &str) -> Result<(), &'static str> {
232 if name.is_empty() || name.len() > 100 {
233 return Err("Repository names must be 1 to 100 characters.");
234 }
235 if !name.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) {
236 return Err("Repository names may only contain letters, digits, '.', '_' and '-'.");
237 }
238 // Leading dots are fine (".github"); the bare dot names and a ".git"
239 // suffix would be ambiguous in URLs and clone paths.
240 if matches!(name, "." | ".." | "-") || name.ends_with(".git") {
241 return Err("Repository names cannot be '.', '..' or end with '.git'.");
242 }
243 Ok(())
244}
245
246/// Image names below the owner: OCI path components joined by '/'.
247pub fn valid_package_name(name: &str) -> Result<(), &'static str> {
248 static PATTERN: once_cell::sync::Lazy<regex::Regex> = once_cell::sync::Lazy::new(|| {
249 regex::Regex::new(r"^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*$").unwrap()
250 });
251 if name.is_empty() || name.len() > 200 || !PATTERN.is_match(name) {
252 return Err("Image names must be lowercase path components like 'api' or 'tools/builder'.");
253 }
254 Ok(())
255}