From 29ed46aa7f566fc2d9de647083d3cc34965b2e1e Mon Sep 17 00:00:00 2001 From: didier amyot Date: Sun, 12 Jul 2026 21:01:09 -0400 Subject: [PATCH] feat: moved to C# thanks to deepseek --- .envrc | 1 + .gitignore | 11 ++ Controllers/ApiController.cs | 45 ++++++ Controllers/HomeController.cs | 22 +++ Controllers/MembreController.cs | 141 +++++++++++++++++++ Models/Game.cs | 15 ++ Models/TeamSelectorModel.cs | 8 ++ Phenix.csproj | 15 ++ Program.cs | 47 +++++++ Services/IGameService.cs | 14 ++ Services/LoginService.cs | 67 +++++++++ Services/PostgresGameService.cs | 200 +++++++++++++++++++++++++++ Services/SqliteGameService.cs | 200 +++++++++++++++++++++++++++ Views/Home/Calendrier.cshtml | 7 + Views/Home/Contact.cshtml | 36 +++++ Views/Home/Index.cshtml | 103 ++++++++++++++ Views/Home/Nouvelles.cshtml | 45 ++++++ Views/Home/Reglement.cshtml | 43 ++++++ Views/Membre/GamesEditor.cshtml | 36 +++++ Views/Membre/Index.cshtml | 28 ++++ Views/Membre/NewGameEditor.cshtml | 35 +++++ Views/Membre/NewGameError.cshtml | 9 ++ Views/Membre/SingleGameEditor.cshtml | 7 + Views/Shared/GameEditor.cshtml | 53 +++++++ Views/Shared/HomeNews.cshtml | 9 ++ Views/Shared/TeamSelector.cshtml | 11 ++ Views/Shared/WalterStewartMap.cshtml | 14 ++ Views/_Layout.cshtml | 86 ++++++++++++ Views/_ViewImports.cshtml | 1 + Views/_ViewStart.cshtml | 3 + appsettings.json | 7 + deps.nix | 12 ++ dev/populate-db/populate.py | 144 +++++++++++++++++++ flake.nix | 122 ++++++++++++++-- 34 files changed, 1583 insertions(+), 14 deletions(-) create mode 100644 .envrc create mode 100644 Controllers/ApiController.cs create mode 100644 Controllers/HomeController.cs create mode 100644 Controllers/MembreController.cs create mode 100644 Models/Game.cs create mode 100644 Models/TeamSelectorModel.cs create mode 100644 Phenix.csproj create mode 100644 Program.cs create mode 100644 Services/IGameService.cs create mode 100644 Services/LoginService.cs create mode 100644 Services/PostgresGameService.cs create mode 100644 Services/SqliteGameService.cs create mode 100644 Views/Home/Calendrier.cshtml create mode 100644 Views/Home/Contact.cshtml create mode 100644 Views/Home/Index.cshtml create mode 100644 Views/Home/Nouvelles.cshtml create mode 100644 Views/Home/Reglement.cshtml create mode 100644 Views/Membre/GamesEditor.cshtml create mode 100644 Views/Membre/Index.cshtml create mode 100644 Views/Membre/NewGameEditor.cshtml create mode 100644 Views/Membre/NewGameError.cshtml create mode 100644 Views/Membre/SingleGameEditor.cshtml create mode 100644 Views/Shared/GameEditor.cshtml create mode 100644 Views/Shared/HomeNews.cshtml create mode 100644 Views/Shared/TeamSelector.cshtml create mode 100644 Views/Shared/WalterStewartMap.cshtml create mode 100644 Views/_Layout.cshtml create mode 100644 Views/_ViewImports.cshtml create mode 100644 Views/_ViewStart.cshtml create mode 100644 appsettings.json create mode 100644 deps.nix create mode 100755 dev/populate-db/populate.py diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore index 5aad357..e63210d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +# Deno node_modules/* lib/* css/* @@ -6,3 +7,13 @@ css/* /main-built.js /api result + +# .NET +bin/ +obj/ +*.user +*.suo +*.db +publish/ + +.direnv/ \ No newline at end of file diff --git a/Controllers/ApiController.cs b/Controllers/ApiController.cs new file mode 100644 index 0000000..4264160 --- /dev/null +++ b/Controllers/ApiController.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Mvc; +using Phenix.Services; + +namespace Phenix.Controllers; + +[Route("/api")] +public class ApiController : Controller +{ + private readonly IGameService _games; + + public ApiController(IGameService games) + { + _games = games; + } + + [HttpGet("results/{year}.json")] + public async Task ResultsJson(int year) + { + var games = await _games.GetAllGames(); + var results = games + .Where(g => g.Year == year) + .Select(g => new Dictionary + { + ["id"] = g.Id, + ["date"] = g.Date, + ["time"] = g.Time, + ["home"] = g.Home, + ["away"] = g.Away, + ["year"] = g.Year, + [g.Home] = g.HomeScore, + [g.Away] = g.AwayScore, + ["practice"] = g.Practice, + ["rain"] = g.Rain, + }); + return Json(results); + } + + [HttpGet("available-years.json")] + public async Task AvailableYearsJson() + { + var games = await _games.GetAllGames(); + var years = games.Select(g => g.Year).Distinct().OrderByDescending(y => y); + return Json(years); + } +} diff --git a/Controllers/HomeController.cs b/Controllers/HomeController.cs new file mode 100644 index 0000000..6b6e59d --- /dev/null +++ b/Controllers/HomeController.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Mvc; +using Phenix.Services; + +namespace Phenix.Controllers; + +public class HomeController : Controller +{ + [HttpGet("/")] + public IActionResult Index() => View(); + + [HttpGet("/calendrier")] + public IActionResult Calendrier() => View(); + + [HttpGet("/nouvelles")] + public IActionResult Nouvelles() => View(); + + [HttpGet("/contact")] + public IActionResult Contact() => View(); + + [HttpGet("/reglement")] + public IActionResult Reglement() => View(); +} diff --git a/Controllers/MembreController.cs b/Controllers/MembreController.cs new file mode 100644 index 0000000..af4829c --- /dev/null +++ b/Controllers/MembreController.cs @@ -0,0 +1,141 @@ +using Microsoft.AspNetCore.Mvc; +using Phenix.Services; + +namespace Phenix.Controllers; + +[Route("/membre")] +public class MembreController : Controller +{ + private readonly IGameService _games; + private readonly LoginService _login; + private const string SessionCookie = "phenix-session"; + private const string SessionSignCookie = "phenix-session-sign"; + + public MembreController(IGameService games, LoginService login) + { + _games = games; + _login = login; + } + + [HttpGet("")] + public IActionResult Index() + { + ViewBag.IsLoggedIn = IsLoggedIn(); + return View(); + } + + [HttpGet("/login")] + public IActionResult Login(string? username, string? password) + { + if (username is null || password is null || !_login.ValidateCredentials(username, password)) + return StatusCode(403); + + var expiresAt = DateTimeOffset.UtcNow.AddDays(90).ToUnixTimeSeconds(); + Response.Cookies.Append(SessionCookie, expiresAt.ToString(), new CookieOptions + { + MaxAge = TimeSpan.FromDays(90), + }); + Response.Cookies.Append(SessionSignCookie, _login.SignSession(expiresAt), new CookieOptions + { + MaxAge = TimeSpan.FromDays(90), + }); + + return Redirect("/membre/games-editor/today"); + } + + [HttpGet("games-editor/today")] + public async Task GamesEditorToday() + { + if (!IsLoggedIn()) return Redirect("/membre"); + var games = await _games.GetAllGames(); + var today = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime( + DateTime.UtcNow, TimeZoneInfo.FindSystemTimeZoneById("America/New_York"))); + + var lastGameDay = games + .Where(g => DateOnly.Parse(g.Date) <= today) + .Select(g => g.Date) + .OrderByDescending(d => d) + .FirstOrDefault(); + + ViewBag.SelectedYear = (int?)null; + ViewBag.IsToday = true; + ViewBag.Years = games.Select(g => g.Year).Distinct().OrderByDescending(y => y).ToList(); + ViewBag.Games = games.Where(g => g.Date == lastGameDay).ToList(); + return PartialView("GamesEditor"); + } + + [HttpGet("games-editor/{year:int}")] + public async Task GamesEditorByYear(int year) + { + if (!IsLoggedIn()) return Redirect("/membre"); + var games = await _games.GetAllGames(); + ViewBag.SelectedYear = year; + ViewBag.IsToday = false; + ViewBag.Years = games.Select(g => g.Year).Distinct().OrderByDescending(y => y).ToList(); + ViewBag.Games = games.Where(g => g.Year == year).ToList(); + return PartialView("GamesEditor"); + } + + [HttpGet("new-game-editor")] + public IActionResult NewGameEditor() + { + if (!IsLoggedIn()) return Redirect("/membre"); + return PartialView(); + } + + [HttpPost("new-game")] + public async Task NewGame(string gameDate, string gameTime, string homeTeam, string awayTeam) + { + if (!IsLoggedIn()) return Redirect("/membre"); + + var (game, error) = await _games.CreateNewGame(gameDate, gameTime, homeTeam, awayTeam); + + if (error != "") + { + ViewBag.Error = error; + return StatusCode(500, PartialView("NewGameError")); + } + + Response.Headers["game-id"] = game!.Id; + return PartialView("SingleGameEditor", game); + } + + [HttpDelete("games/{gameId}")] + public async Task DeleteGame(string gameId) + { + if (!IsLoggedIn()) return Redirect("/membre"); + var ok = await _games.DeleteGameById(gameId); + return Content(ok ? "true" : "false"); + } + + [HttpPost("games/{gameId}")] + public async Task UpdateGame(string gameId, int? awayScore, int? homeScore, string? gameType) + { + if (!IsLoggedIn()) return Redirect("/membre"); + var success = await _games.UpdateGame(gameId, homeScore, awayScore, + rain: gameType == "rain", practice: gameType == "practice"); + return Content(success + ? "" + : ""); + } + + [HttpPost("games/{gameId}/switch")] + public async Task SwitchVisitors(string gameId) + { + if (!IsLoggedIn()) return Redirect("/membre"); + var success = await _games.SwitchVisitors(gameId); + if (!success) + return Content(""); + + var game = await _games.GetGameById(gameId); + Response.Headers["game-id"] = gameId; + return PartialView("SingleGameEditor", game); + } + + private bool IsLoggedIn() + { + Request.Cookies.TryGetValue(SessionCookie, out var expires); + Request.Cookies.TryGetValue(SessionSignCookie, out var sig); + return _login.VerifySession(expires, sig); + } +} diff --git a/Models/Game.cs b/Models/Game.cs new file mode 100644 index 0000000..c7a760e --- /dev/null +++ b/Models/Game.cs @@ -0,0 +1,15 @@ +namespace Phenix.Models; + +public class Game +{ + public string Id { get; set; } = ""; + public string Date { get; set; } = ""; + public string Time { get; set; } = ""; + public string Home { get; set; } = ""; + public string Away { get; set; } = ""; + public int Year { get; set; } + public int? HomeScore { get; set; } + public int? AwayScore { get; set; } + public bool Practice { get; set; } + public bool Rain { get; set; } +} diff --git a/Models/TeamSelectorModel.cs b/Models/TeamSelectorModel.cs new file mode 100644 index 0000000..3c49282 --- /dev/null +++ b/Models/TeamSelectorModel.cs @@ -0,0 +1,8 @@ +namespace Phenix.Models; + +public class TeamSelectorModel +{ + public string Name { get; set; } = ""; + public string SelectedTeam { get; set; } = ""; + public bool IsDisabled { get; set; } +} diff --git a/Phenix.csproj b/Phenix.csproj new file mode 100644 index 0000000..a3b7f6c --- /dev/null +++ b/Phenix.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + Phenix + enable + enable + + + + + + + + diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..89230d9 --- /dev/null +++ b/Program.cs @@ -0,0 +1,47 @@ +using Phenix.Services; + +var builder = WebApplication.CreateBuilder(new WebApplicationOptions +{ + Args = args, + WebRootPath = "static", +}); + +builder.Services + .AddControllersWithViews() + .AddRazorOptions(o => + { + o.ViewLocationFormats.Clear(); + o.ViewLocationFormats.Add("/Views/{1}/{0}.cshtml"); + o.ViewLocationFormats.Add("/Views/Shared/{0}.cshtml"); + o.ViewLocationFormats.Add("/Views/{0}.cshtml"); + }); + +builder.Services.AddSingleton(sp => +{ + var config = sp.GetRequiredService(); + var connStr = config.GetConnectionString("Default") + ?? "Data Source=phenix.db"; + var provider = config["DatabaseProvider"] ?? "Sqlite"; + + IGameService svc = provider.ToLowerInvariant() switch + { + "postgres" or "postgresql" => new PostgresGameService(connStr), + _ => new SqliteGameService(connStr), + }; + svc.Init().GetAwaiter().GetResult(); + return svc; +}); + +builder.Services.AddSingleton(sp => +{ + var key = sp.GetRequiredService()["SessionKey"] ?? "dev-only"; + return new LoginService(key); +}); + +var app = builder.Build(); + +app.UseStaticFiles(); + +app.MapControllers(); + +app.Run(); diff --git a/Services/IGameService.cs b/Services/IGameService.cs new file mode 100644 index 0000000..8461126 --- /dev/null +++ b/Services/IGameService.cs @@ -0,0 +1,14 @@ +using Phenix.Models; + +namespace Phenix.Services; + +public interface IGameService +{ + Task Init(); + Task> GetAllGames(); + Task GetGameById(string id); + Task<(Game? Game, string Error)> CreateNewGame(string date, string time, string home, string away); + Task UpdateGame(string id, int? homeScore, int? awayScore, bool? rain, bool? practice); + Task DeleteGameById(string id); + Task SwitchVisitors(string id); +} diff --git a/Services/LoginService.cs b/Services/LoginService.cs new file mode 100644 index 0000000..e01b32c --- /dev/null +++ b/Services/LoginService.cs @@ -0,0 +1,67 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Phenix.Services; + +public class LoginService +{ + private readonly Dictionary _users; + private readonly string _sessionKey; + + private static readonly (string User, string Hash)[] OfficialUsers = + [ + ("marqueur", "34373780cc542a1eddb6a08488ec149323b94d66025421acd98ffc20eb5055a0:6f2fa05777d45a2da9db79b7fd11e3bc69eb1807642ccb53d41a0b9eb71a3311"), + ("comite", "8649feff48a50f53448e59ce480db97b5549eef1d92c886c49b5a14ee22648e5:a0017b879c8834d2f34938e7a4fa3f4c7be8f70545b9bb22a28cf56ab2eb26d9"), + ]; + + public LoginService(string sessionKey) + { + _users = OfficialUsers.ToDictionary(x => x.User, x => x.Hash); + _sessionKey = sessionKey; + } + + public bool ValidateCredentials(string username, string password) + { + if (!_users.TryGetValue(username, out var hashWithSalt)) + return false; + return VerifyPassword(password, hashWithSalt); + } + + public string SignSession(long expiresAt) + { + return ShittyHmac(expiresAt.ToString(), _sessionKey); + } + + public bool VerifySession(string? expiresAt, string? signature) + { + if (expiresAt is null || signature is null) + return false; + if (!long.TryParse(expiresAt, out var ts)) + return false; + if (signature != ShittyHmac(ts.ToString(), _sessionKey)) + return false; + return DateTimeOffset.UtcNow.ToUnixTimeSeconds() < ts; + } + + private static bool VerifyPassword(string password, string hashWithSalt) + { + var parts = hashWithSalt.Split(':'); + if (parts.Length != 2) return false; + var salt = parts[0]; + var expected = parts[1]; + return ShittyHmac(password, salt) == expected; + } + + private static string HashMessage(string message) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(message)); + return Convert.ToHexStringLower(bytes); + } + + private static string ShittyHmac(string message, string salt) + { + var h1 = HashMessage(message + salt); + var h2 = HashMessage(salt + message); + return HashMessage(h1 + h2); + } +} diff --git a/Services/PostgresGameService.cs b/Services/PostgresGameService.cs new file mode 100644 index 0000000..ebf68df --- /dev/null +++ b/Services/PostgresGameService.cs @@ -0,0 +1,200 @@ +using System.Data.Common; +using Npgsql; +using Phenix.Models; + +namespace Phenix.Services; + +public class PostgresGameService : IGameService +{ + private readonly string _connectionString; + + public PostgresGameService(string connectionString) + { + _connectionString = connectionString; + } + + public async Task Init() + { + await using var conn = new NpgsqlConnection(_connectionString); + await conn.OpenAsync(); + var cmd = conn.CreateCommand(); + cmd.CommandText = """ + CREATE TABLE IF NOT EXISTS games ( + id TEXT PRIMARY KEY, + date TEXT NOT NULL, + time TEXT NOT NULL, + home TEXT NOT NULL, + away TEXT NOT NULL, + year INTEGER NOT NULL, + home_score INTEGER, + away_score INTEGER, + practice BOOLEAN NOT NULL DEFAULT FALSE, + rain BOOLEAN NOT NULL DEFAULT FALSE, + UNIQUE(date, time) + ) + """; + await cmd.ExecuteNonQueryAsync(); + } + + public async Task> GetAllGames() + { + var games = new List(); + await using var conn = new NpgsqlConnection(_connectionString); + await conn.OpenAsync(); + var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT * FROM games ORDER BY date, time"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + games.Add(ReadGame(reader)); + return games; + } + + public async Task GetGameById(string id) + { + await using var conn = new NpgsqlConnection(_connectionString); + await conn.OpenAsync(); + var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT * FROM games WHERE id = @id"; + cmd.Parameters.AddWithValue("@id", id); + await using var reader = await cmd.ExecuteReaderAsync(); + if (await reader.ReadAsync()) + return ReadGame(reader); + return null; + } + + public async Task<(Game? Game, string Error)> CreateNewGame(string date, string time, string home, string away) + { + date = CleanDate(date); + time = CleanTime(time); + + if (!DateOnly.TryParse(date, out _)) + return (null, $"Invalide date format {date}"); + if (!TimeOnly.TryParse(time, out _)) + return (null, $"Invalide time format {time}"); + if (!ValidTeams.Contains(away)) + return (null, $"Invalide away format {away}"); + if (!ValidTeams.Contains(home)) + return (null, $"Invalide home format {home}"); + if (home == away) + return (null, $"Home and away teams must be different (received {home} for both)"); + + var year = DateOnly.Parse(date).Year; + var game = new Game + { + Id = Guid.NewGuid().ToString(), + Date = date, + Time = time, + Home = home, + Away = away, + Year = year, + }; + + try + { + await using var conn = new NpgsqlConnection(_connectionString); + await conn.OpenAsync(); + var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO games (id, date, time, home, away, year) + VALUES (@id, @date, @time, @home, @away, @year) + """; + cmd.Parameters.AddWithValue("@id", game.Id); + cmd.Parameters.AddWithValue("@date", game.Date); + cmd.Parameters.AddWithValue("@time", game.Time); + cmd.Parameters.AddWithValue("@home", game.Home); + cmd.Parameters.AddWithValue("@away", game.Away); + cmd.Parameters.AddWithValue("@year", game.Year); + await cmd.ExecuteNonQueryAsync(); + } + catch (PostgresException ex) when (ex.SqlState == "23505") + { + return (null, "game already exists at this date/time"); + } + + return (game, ""); + } + + public async Task UpdateGame(string id, int? homeScore, int? awayScore, bool? rain, bool? practice) + { + await using var conn = new NpgsqlConnection(_connectionString); + await conn.OpenAsync(); + var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE games SET + home_score = COALESCE(@homeScore, home_score), + away_score = COALESCE(@awayScore, away_score), + rain = COALESCE(@rain, rain), + practice = COALESCE(@practice, practice) + WHERE id = @id + """; + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@homeScore", (object?)homeScore ?? DBNull.Value); + cmd.Parameters.AddWithValue("@awayScore", (object?)awayScore ?? DBNull.Value); + cmd.Parameters.AddWithValue("@rain", rain ?? (object?)DBNull.Value); + cmd.Parameters.AddWithValue("@practice", practice ?? (object?)DBNull.Value); + return await cmd.ExecuteNonQueryAsync() > 0; + } + + public async Task DeleteGameById(string id) + { + await using var conn = new NpgsqlConnection(_connectionString); + await conn.OpenAsync(); + var cmd = conn.CreateCommand(); + cmd.CommandText = "DELETE FROM games WHERE id = @id"; + cmd.Parameters.AddWithValue("@id", id); + return await cmd.ExecuteNonQueryAsync() > 0; + } + + public async Task SwitchVisitors(string id) + { + var game = await GetGameById(id); + if (game is null) return false; + + await using var conn = new NpgsqlConnection(_connectionString); + await conn.OpenAsync(); + var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE games SET home = @home, away = @away, + home_score = @homeScore, away_score = @awayScore + WHERE id = @id + """; + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@home", game.Away); + cmd.Parameters.AddWithValue("@away", game.Home); + cmd.Parameters.AddWithValue("@homeScore", (object?)game.AwayScore ?? DBNull.Value); + cmd.Parameters.AddWithValue("@awayScore", (object?)game.HomeScore ?? DBNull.Value); + return await cmd.ExecuteNonQueryAsync() > 0; + } + + private static Game ReadGame(DbDataReader r) => new() + { + Id = r.GetString(r.GetOrdinal("id")), + Date = r.GetString(r.GetOrdinal("date")), + Time = r.GetString(r.GetOrdinal("time")), + Home = r.GetString(r.GetOrdinal("home")), + Away = r.GetString(r.GetOrdinal("away")), + Year = r.GetInt32(r.GetOrdinal("year")), + HomeScore = r.IsDBNull(r.GetOrdinal("home_score")) ? null : r.GetInt32(r.GetOrdinal("home_score")), + AwayScore = r.IsDBNull(r.GetOrdinal("away_score")) ? null : r.GetInt32(r.GetOrdinal("away_score")), + Practice = r.GetBoolean(r.GetOrdinal("practice")), + Rain = r.GetBoolean(r.GetOrdinal("rain")), + }; + + private static readonly string[] ValidTeams = + ["jaune", "rouge", "bleu", "gris", "vert", "noir", "orange", "mauve"]; + + private static string CleanDate(string raw) + { + var parts = raw.Split('-'); + if (parts.Length != 3) return raw; + return $"{parts[0]}-{parts[1].PadLeft(2, '0')}-{parts[2].PadLeft(2, '0')}"; + } + + private static string CleanTime(string raw) + { + var sep = raw.Contains('h') ? 'h' : ':'; + var parts = raw.Split(sep); + if (parts.Length != 2) return raw; + return $"{parts[0].PadLeft(2, '0')}:{parts[1].PadLeft(2, '0')}"; + } +} diff --git a/Services/SqliteGameService.cs b/Services/SqliteGameService.cs new file mode 100644 index 0000000..ba1d367 --- /dev/null +++ b/Services/SqliteGameService.cs @@ -0,0 +1,200 @@ +using System.Data.Common; +using Microsoft.Data.Sqlite; +using Phenix.Models; + +namespace Phenix.Services; + +public class SqliteGameService : IGameService +{ + private readonly string _connectionString; + + public SqliteGameService(string connectionString) + { + _connectionString = connectionString; + } + + public async Task Init() + { + await using var conn = new SqliteConnection(_connectionString); + await conn.OpenAsync(); + var cmd = conn.CreateCommand(); + cmd.CommandText = """ + CREATE TABLE IF NOT EXISTS games ( + id TEXT PRIMARY KEY, + date TEXT NOT NULL, + time TEXT NOT NULL, + home TEXT NOT NULL, + away TEXT NOT NULL, + year INTEGER NOT NULL, + home_score INTEGER, + away_score INTEGER, + practice INTEGER NOT NULL DEFAULT 0, + rain INTEGER NOT NULL DEFAULT 0, + UNIQUE(date, time) + ) + """; + await cmd.ExecuteNonQueryAsync(); + } + + public async Task> GetAllGames() + { + var games = new List(); + await using var conn = new SqliteConnection(_connectionString); + await conn.OpenAsync(); + var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT * FROM games ORDER BY date, time"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + games.Add(ReadGame(reader)); + return games; + } + + public async Task GetGameById(string id) + { + await using var conn = new SqliteConnection(_connectionString); + await conn.OpenAsync(); + var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT * FROM games WHERE id = @id"; + cmd.Parameters.AddWithValue("@id", id); + await using var reader = await cmd.ExecuteReaderAsync(); + if (await reader.ReadAsync()) + return ReadGame(reader); + return null; + } + + public async Task<(Game? Game, string Error)> CreateNewGame(string date, string time, string home, string away) + { + date = CleanDate(date); + time = CleanTime(time); + + if (!DateOnly.TryParse(date, out _)) + return (null, $"Invalide date format {date}"); + if (!TimeOnly.TryParse(time, out _)) + return (null, $"Invalide time format {time}"); + if (!ValidTeams.Contains(away)) + return (null, $"Invalide away format {away}"); + if (!ValidTeams.Contains(home)) + return (null, $"Invalide home format {home}"); + if (home == away) + return (null, $"Home and away teams must be different (received {home} for both)"); + + var year = DateOnly.Parse(date).Year; + var game = new Game + { + Id = Guid.NewGuid().ToString(), + Date = date, + Time = time, + Home = home, + Away = away, + Year = year, + }; + + try + { + await using var conn = new SqliteConnection(_connectionString); + await conn.OpenAsync(); + var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO games (id, date, time, home, away, year) + VALUES (@id, @date, @time, @home, @away, @year) + """; + cmd.Parameters.AddWithValue("@id", game.Id); + cmd.Parameters.AddWithValue("@date", game.Date); + cmd.Parameters.AddWithValue("@time", game.Time); + cmd.Parameters.AddWithValue("@home", game.Home); + cmd.Parameters.AddWithValue("@away", game.Away); + cmd.Parameters.AddWithValue("@year", game.Year); + await cmd.ExecuteNonQueryAsync(); + } + catch (SqliteException ex) when (ex.SqliteErrorCode == 19) + { + return (null, "game already exists at this date/time"); + } + + return (game, ""); + } + + public async Task UpdateGame(string id, int? homeScore, int? awayScore, bool? rain, bool? practice) + { + await using var conn = new SqliteConnection(_connectionString); + await conn.OpenAsync(); + var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE games SET + home_score = COALESCE(@homeScore, home_score), + away_score = COALESCE(@awayScore, away_score), + rain = COALESCE(@rain, rain), + practice = COALESCE(@practice, practice) + WHERE id = @id + """; + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@homeScore", (object?)homeScore ?? DBNull.Value); + cmd.Parameters.AddWithValue("@awayScore", (object?)awayScore ?? DBNull.Value); + cmd.Parameters.AddWithValue("@rain", rain ?? (object?)DBNull.Value); + cmd.Parameters.AddWithValue("@practice", practice ?? (object?)DBNull.Value); + return await cmd.ExecuteNonQueryAsync() > 0; + } + + public async Task DeleteGameById(string id) + { + await using var conn = new SqliteConnection(_connectionString); + await conn.OpenAsync(); + var cmd = conn.CreateCommand(); + cmd.CommandText = "DELETE FROM games WHERE id = @id"; + cmd.Parameters.AddWithValue("@id", id); + return await cmd.ExecuteNonQueryAsync() > 0; + } + + public async Task SwitchVisitors(string id) + { + var game = await GetGameById(id); + if (game is null) return false; + + await using var conn = new SqliteConnection(_connectionString); + await conn.OpenAsync(); + var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE games SET home = @home, away = @away, + home_score = @homeScore, away_score = @awayScore + WHERE id = @id + """; + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@home", game.Away); + cmd.Parameters.AddWithValue("@away", game.Home); + cmd.Parameters.AddWithValue("@homeScore", (object?)game.AwayScore ?? DBNull.Value); + cmd.Parameters.AddWithValue("@awayScore", (object?)game.HomeScore ?? DBNull.Value); + return await cmd.ExecuteNonQueryAsync() > 0; + } + + private static Game ReadGame(DbDataReader r) => new() + { + Id = r.GetString(r.GetOrdinal("id")), + Date = r.GetString(r.GetOrdinal("date")), + Time = r.GetString(r.GetOrdinal("time")), + Home = r.GetString(r.GetOrdinal("home")), + Away = r.GetString(r.GetOrdinal("away")), + Year = r.GetInt32(r.GetOrdinal("year")), + HomeScore = r.IsDBNull(r.GetOrdinal("home_score")) ? null : r.GetInt32(r.GetOrdinal("home_score")), + AwayScore = r.IsDBNull(r.GetOrdinal("away_score")) ? null : r.GetInt32(r.GetOrdinal("away_score")), + Practice = r.GetBoolean(r.GetOrdinal("practice")), + Rain = r.GetBoolean(r.GetOrdinal("rain")), + }; + + private static readonly string[] ValidTeams = + ["jaune", "rouge", "bleu", "gris", "vert", "noir", "orange", "mauve"]; + + private static string CleanDate(string raw) + { + var parts = raw.Split('-'); + if (parts.Length != 3) return raw; + return $"{parts[0]}-{parts[1].PadLeft(2, '0')}-{parts[2].PadLeft(2, '0')}"; + } + + private static string CleanTime(string raw) + { + var sep = raw.Contains('h') ? 'h' : ':'; + var parts = raw.Split(sep); + if (parts.Length != 2) return raw; + return $"{parts[0].PadLeft(2, '0')}:{parts[1].PadLeft(2, '0')}"; + } +} diff --git a/Views/Home/Calendrier.cshtml b/Views/Home/Calendrier.cshtml new file mode 100644 index 0000000..fdb761d --- /dev/null +++ b/Views/Home/Calendrier.cshtml @@ -0,0 +1,7 @@ +@{ + Layout = "_Layout"; + ViewData["Tab"] = "calendrier"; +} + +
+ diff --git a/Views/Home/Contact.cshtml b/Views/Home/Contact.cshtml new file mode 100644 index 0000000..1174226 --- /dev/null +++ b/Views/Home/Contact.cshtml @@ -0,0 +1,36 @@ +@{ + Layout = "_Layout"; + ViewData["Tab"] = "contact"; +} + +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + +
sportsphenixmtl@gmail.com
(514) 838-8950
+

10454 Sylvain-Garneau
Montréal, Québec H1C 0A2

+
+

Le site web est ouvert et accessible sur gitlab.

+
+
+
diff --git a/Views/Home/Index.cshtml b/Views/Home/Index.cshtml new file mode 100644 index 0000000..04bcc32 --- /dev/null +++ b/Views/Home/Index.cshtml @@ -0,0 +1,103 @@ +@{ + Layout = "_Layout"; + ViewData["Tab"] = "home"; +} + + + +
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+

Calendrier

+
+
+
+
+
+
+

Parc Walter-Stewart

+ +
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+

Calendrier

+
+
+
+
+
+
+
+
+

Parc Walter-Stewart

+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+

Calendrier

+
+
+
+
+
+
+
+
+

Parc Walter-Stewart

+ +
+
+
+
+
+
diff --git a/Views/Home/Nouvelles.cshtml b/Views/Home/Nouvelles.cshtml new file mode 100644 index 0000000..a2218e3 --- /dev/null +++ b/Views/Home/Nouvelles.cshtml @@ -0,0 +1,45 @@ +@{ + Layout = "_Layout"; + ViewData["Tab"] = "nouvelles"; +} + +
+
+
+

+

Saison 2026

+ +

Lundi 9 Mars 2026

+

+

+ La saison de la balle annonce à grand pas et les inscriptions débuteront lundi prochain (16 mars 2026). Le coût de la saison sera le même que l'an passé: 155$ pour la saison + 20$ de dépôt remboursable pour le prêt du chandail. +

+

+ Comme à l'habitude, nous jouerons au parc walter-stewart les jeudis soir. La ligue comptera quatre équipes pour un grand total de 44 joueurs. +

+

+ Les inscriptions en présentiel auront lieu le vendredi 20 mars au bar fullum entre 18h00 et 21h00. Pour ceux qui veulle venir prendre une petite bière avec nous ou payer en argent compten. +

+

+ On vous tiens au courrant! +

+
+
+
+ +
+
+
+

+

Saison 2025

+ +

Vendredi 25 Avril 2025

+

+

Bonne saison à tous et toutes!

+

N'oubliez pas de consulter la page facebook: https://www.facebook.com/SportsPhenixMtl/ pour toutes mises à jour, y compris les annulations due à la météo.

+

Votre Comité 2023

+
+
+
diff --git a/Views/Home/Reglement.cshtml b/Views/Home/Reglement.cshtml new file mode 100644 index 0000000..3992a67 --- /dev/null +++ b/Views/Home/Reglement.cshtml @@ -0,0 +1,43 @@ +@{ + Layout = "_Layout"; + ViewData["Tab"] = "reglement"; +} + +
+
+ +
+

Mission

+

La ligue de balle Les Phénix a été fondée en 2004 afin d'offrir aux membres de la communauté gaie et lesbienne et à leurs amis hétéros l'opportunité de s'amuser et de pratiquer un sport dans une ambiance amicale et décontractée. L'ouverture d'esprit, la camaraderie et le plaisir de pratiquer un sport à l'extérieur caractérisent cette ligue.

+
+
+
diff --git a/Views/Membre/GamesEditor.cshtml b/Views/Membre/GamesEditor.cshtml new file mode 100644 index 0000000..493fab2 --- /dev/null +++ b/Views/Membre/GamesEditor.cshtml @@ -0,0 +1,36 @@ +@{ + var isToday = ViewBag.IsToday == true; + var years = (List)ViewBag.Years; + var games = (List)ViewBag.Games; + var selectedYear = (int?)ViewBag.SelectedYear; +} + +
+
+ +
+ + @foreach (var game in games) + { +
+ +
+ } + +
+ +
diff --git a/Views/Membre/Index.cshtml b/Views/Membre/Index.cshtml new file mode 100644 index 0000000..bc52f66 --- /dev/null +++ b/Views/Membre/Index.cshtml @@ -0,0 +1,28 @@ +@{ + Layout = "_Layout"; + ViewData["Tab"] = "membre"; +} + +
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/Membre/NewGameEditor.cshtml b/Views/Membre/NewGameEditor.cshtml new file mode 100644 index 0000000..4759e2e --- /dev/null +++ b/Views/Membre/NewGameEditor.cshtml @@ -0,0 +1,35 @@ +@{ + Layout = null; +} + +
+
+ +
+
+
+ + + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+ +
+
diff --git a/Views/Membre/NewGameError.cshtml b/Views/Membre/NewGameError.cshtml new file mode 100644 index 0000000..a1ac594 --- /dev/null +++ b/Views/Membre/NewGameError.cshtml @@ -0,0 +1,9 @@ +@{ + Layout = null; +} + +
+
+
@ViewBag.Error
+
+
diff --git a/Views/Membre/SingleGameEditor.cshtml b/Views/Membre/SingleGameEditor.cshtml new file mode 100644 index 0000000..28ea394 --- /dev/null +++ b/Views/Membre/SingleGameEditor.cshtml @@ -0,0 +1,7 @@ +@model Phenix.Models.Game + +
+
+ +
+
diff --git a/Views/Shared/GameEditor.cshtml b/Views/Shared/GameEditor.cshtml new file mode 100644 index 0000000..37c1254 --- /dev/null +++ b/Views/Shared/GameEditor.cshtml @@ -0,0 +1,53 @@ +@model Phenix.Models.Game +@{ + var prettyDate = DateOnly.Parse(Model.Date).ToString("d MMMM yyyy", new System.Globalization.CultureInfo("fr-CA")); +} + +
+

@Model.Away vs @Model.Home | @prettyDate | @Model.Time

+
+
+ +
+
+ +
+
+ + + +
+
+
+ +
+ + +
+
diff --git a/Views/Shared/HomeNews.cshtml b/Views/Shared/HomeNews.cshtml new file mode 100644 index 0000000..ca1b012 --- /dev/null +++ b/Views/Shared/HomeNews.cshtml @@ -0,0 +1,9 @@ +

+ +

Saison 2026

+
+

+

+ La saison de la balle annonce à grand pas et les inscriptions débuteront lundi prochain (16 mars 2026). Le coût de la saison sera le même que l'an passé: 155$ pour la saison + 20$ de dépôt remboursable pour le prêt du chandail. +

+

lire la suite ...

diff --git a/Views/Shared/TeamSelector.cshtml b/Views/Shared/TeamSelector.cshtml new file mode 100644 index 0000000..db8c4e5 --- /dev/null +++ b/Views/Shared/TeamSelector.cshtml @@ -0,0 +1,11 @@ +@model Phenix.Models.TeamSelectorModel +@{ + var teams = new[] { "jaune", "rouge", "bleu", "gris", "vert", "noir", "orange", "mauve" }; +} + + diff --git a/Views/Shared/WalterStewartMap.cshtml b/Views/Shared/WalterStewartMap.cshtml new file mode 100644 index 0000000..9a4dba9 --- /dev/null +++ b/Views/Shared/WalterStewartMap.cshtml @@ -0,0 +1,14 @@ +
+ +
+ + Voir la carte + +
diff --git a/Views/_Layout.cshtml b/Views/_Layout.cshtml new file mode 100644 index 0000000..0bd8645 --- /dev/null +++ b/Views/_Layout.cshtml @@ -0,0 +1,86 @@ +@{ + Layout = null; +} + + + + + Balle Phenix + + + + + + + + + + + + @RenderBody() + +
+
+
Ligue de balle les Phénix... pour le plaisir de jouer! +

Ligue de balle les Phénix

+
+ +

+
+
+ + diff --git a/Views/_ViewImports.cshtml b/Views/_ViewImports.cshtml new file mode 100644 index 0000000..a757b41 --- /dev/null +++ b/Views/_ViewImports.cshtml @@ -0,0 +1 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/Views/_ViewStart.cshtml b/Views/_ViewStart.cshtml new file mode 100644 index 0000000..820a2f6 --- /dev/null +++ b/Views/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..91f76b3 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,7 @@ +{ + "DatabaseProvider": "Sqlite", + "ConnectionStrings": { + "Default": "Data Source=phenix.db" + }, + "SessionKey": "dev-only" +} diff --git a/deps.nix b/deps.nix new file mode 100644 index 0000000..b4eaf94 --- /dev/null +++ b/deps.nix @@ -0,0 +1,12 @@ +{ fetchNuGet }: [ + (fetchNuGet { pname = "Microsoft.Data.Sqlite"; version = "9.0.0"; sha256 = "0m58j9cdrkk8m0yl24z8jy3iafxfbwmf3v01cwhwnqs0cbq1ara8"; }) + (fetchNuGet { pname = "Microsoft.Data.Sqlite.Core"; version = "9.0.0"; sha256 = "11s3pf12x4ghi1xn2dv6dsj3ckxq062m82dcinn2l42z590ny5fg"; }) + (fetchNuGet { pname = "Microsoft.Extensions.DependencyInjection.Abstractions"; version = "8.0.0"; sha256 = "1zw0bpp5742jzx03wvqc8csnvsbgdqi0ls9jfc5i2vd3cl8b74pg"; }) + (fetchNuGet { pname = "Microsoft.Extensions.Logging.Abstractions"; version = "8.0.0"; sha256 = "1klcqhg3hk55hb6vmjiq2wgqidsl81aldw0li2z98lrwx26msrr6"; }) + (fetchNuGet { pname = "Npgsql"; version = "8.0.0"; sha256 = "1x3jlin44frkhd7d50q0vmy0gapxqhmd4jv1nvsnkndp3xjsaynd"; }) + (fetchNuGet { pname = "SQLitePCLRaw.bundle_e_sqlite3"; version = "2.1.10"; sha256 = "1irrg46sv2w12nh6jc810clbi06fgclnbky14b8kamfkgy61d4li"; }) + (fetchNuGet { pname = "SQLitePCLRaw.core"; version = "2.1.10"; sha256 = "03q02p1cajqzcbk9112mgw3qq733l4gd2vi21dq2am4911imr5l2"; }) + (fetchNuGet { pname = "SQLitePCLRaw.lib.e_sqlite3"; version = "2.1.10"; sha256 = "0xqa3l4p68vm3p5n4h3d2gs97vrpnvhwfv366i6fv5x80m2zcswv"; }) + (fetchNuGet { pname = "SQLitePCLRaw.provider.e_sqlite3"; version = "2.1.10"; sha256 = "0n7cdwix5h9k578a07g00sw9c03sv4liy91qzpj9wb8k4673gfrh"; }) + (fetchNuGet { pname = "System.Memory"; version = "4.5.3"; sha256 = "0naqahm3wljxb5a911d37mwjqjdxv9l0b49p5dmfyijvni2ppy8a"; }) +] diff --git a/dev/populate-db/populate.py b/dev/populate-db/populate.py new file mode 100755 index 0000000..42ceb18 --- /dev/null +++ b/dev/populate-db/populate.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +import argparse +import os +import sqlite3 +import sys +from pathlib import Path + +import requests + + +def find_project_root(): + direnv_file = os.environ.get("DIRENV_FILE") + if direnv_file: + return Path(direnv_file).parent.resolve() + + direnv_dir = os.environ.get("DIRENV_DIR") + if direnv_dir: + return Path(direnv_dir).resolve() + + cwd = Path.cwd().resolve() + for parent in [cwd] + list(cwd.parents): + if (parent / "flake.nix").exists(): + return parent + + raise RuntimeError( + "Cannot find project root. Set DIRENV_FILE or DIRENV_DIR, " + "or run from within the project tree." + ) + + +def fetch_json(api_root, path): + url = f"{api_root}{path}" + resp = requests.get(url, timeout=30) + resp.raise_for_status() + return resp.json() + + +def main(): + parser = argparse.ArgumentParser( + description="Populate a local SQLite DB from production data" + ) + parser.add_argument( + "--url", + default=os.environ.get("PHENIX_URL", "https://ballephenix.com"), + help="Source API root (default: https://ballephenix.com)", + ) + parser.add_argument( + "--db", + default=None, + help="Path to SQLite DB (default: /phenix.db)", + ) + parser.add_argument( + "--force", + action="store_true", + help="Clear existing games before inserting", + ) + args = parser.parse_args() + + api_root = args.url.rstrip("/") + + project_root = find_project_root() + db_path = Path(args.db) if args.db else project_root / "phenix.db" + + years = fetch_json(api_root, "/api/available-years.json") + print(f"Years on source: {len(years)} ({min(years)}–{max(years)})") + + all_games = [] + for year in years: + games = fetch_json(api_root, f"/api/results/{year}.json") + all_games.extend(games) + print(f"Total games fetched: {len(all_games)}") + + conn = sqlite3.connect(str(db_path)) + cursor = conn.cursor() + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS games ( + id TEXT PRIMARY KEY, + date TEXT NOT NULL, + time TEXT NOT NULL, + home TEXT NOT NULL, + away TEXT NOT NULL, + year INTEGER NOT NULL, + home_score INTEGER, + away_score INTEGER, + practice INTEGER NOT NULL DEFAULT 0, + rain INTEGER NOT NULL DEFAULT 0, + UNIQUE(date, time) + ) + """) + + if args.force: + cursor.execute("DELETE FROM games") + print("Cleared existing games") + + inserted = 0 + skipped = 0 + errors = 0 + + for g in all_games: + try: + home_score = int(g["homeScore"]) if g.get("homeScore") is not None else None + away_score = int(g["awayScore"]) if g.get("awayScore") is not None else None + except (ValueError, TypeError): + home_score = None + away_score = None + + try: + cursor.execute( + """ + INSERT OR IGNORE INTO games + (id, date, time, home, away, year, home_score, away_score, practice, rain) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + g.get("id", ""), + g["date"], + g.get("time", ""), + g.get("home", ""), + g.get("away", ""), + g.get("year", 0), + home_score, + away_score, + 1 if g.get("practice") else 0, + 1 if g.get("rain") else 0, + ), + ) + if cursor.rowcount > 0: + inserted += 1 + else: + skipped += 1 + except sqlite3.Error as e: + print(f"DB error on {g.get('date')} {g.get('time')}: {e}", file=sys.stderr) + errors += 1 + + conn.commit() + conn.close() + + print(f"Inserted: {inserted}, skipped: {skipped}, errors: {errors}") + print(f"Database: {db_path}") + + +if __name__ == "__main__": + main() diff --git a/flake.nix b/flake.nix index 7808242..e3af3c7 100644 --- a/flake.nix +++ b/flake.nix @@ -1,32 +1,126 @@ { - description = "Phenix website"; + description = "Balle Phenix website — ASP.NET + SQLite"; inputs = { nixpkgs.url = "nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; }; - outputs = { self, nixpkgs, flake-utils }: - + outputs = { self, nixpkgs, flake-utils, ... }: flake-utils.lib.eachDefaultSystem (system: let pkgs = nixpkgs.legacyPackages.${system}; - build-static-files = pkgs.stdenv.mkDerivation { - name = "phenix-website-static-assets"; - src = ./static; - buildPhase = '' - find . -type f -exec ${pkgs.brotli}/bin/brotli -9 -f -k {} \; - ''; - installPhase = '' - cp -r . $out + dotnet = pkgs.dotnetCorePackages.sdk_9_0; + pythonEnv = pkgs.python3.withPackages (ps: [ ps.requests ]); + phenix-website = pkgs.buildDotnetModule { + name = "phenix-website"; + src = ./.; + projectFile = "Phenix.csproj"; + selfContainedBuild = true; + dotnet-sdk = dotnet; + dotnet-runtime = pkgs.dotnetCorePackages.runtime_9_0; + runtimeIdentifiers = "linux-x64"; + nugetDeps = ./deps.nix; + + postInstall = '' + cp -r static $out/lib/static/ + cp appsettings.json $out/lib/ ''; + meta.mainProgram = "Phenix"; }; in { - packages = { static = build-static-files; }; + packages = { + default = phenix-website; + website = phenix-website; + }; + devShell = pkgs.mkShell { name = "Phenix-Website"; - buildInputs = with pkgs; [ deno ]; + buildInputs = [ dotnet pkgs.nuget-to-nix pythonEnv ]; + + shellHook = '' + export PATH="$PWD/dev/populate-db:$PATH" + ''; }; - }); + }) // { + + nixosModules.default = { config, lib, pkgs, ... }: + let + cfg = config.services.phenix-website; + phenix = self.packages.${pkgs.system}.website; + pythonEnv = pkgs.python3.withPackages (ps: [ ps.requests ]); + in { + options.services.phenix-website = { + enable = lib.mkEnableOption "Balle Phenix website"; + domain = lib.mkOption { + type = lib.types.str; + default = "ballephenix.com"; + }; + port = lib.mkOption { + type = lib.types.port; + default = 8000; + }; + dataDir = lib.mkOption { + type = lib.types.path; + default = "/var/lib/phenix"; + }; + sessionKey = lib.mkOption { + type = lib.types.str; + default = ""; + }; + }; + + config = lib.mkIf cfg.enable { + systemd.services.phenix-website = { + description = "Balle Phenix website"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + serviceConfig = { + Type = "simple"; + ExecStart = "${phenix}/lib/Phenix"; + WorkingDirectory = "${phenix}/lib/"; + Restart = "always"; + RestartSec = 5; + User = "phenix"; + Group = "phenix"; + }; + + environment = { + ASPNETCORE_URLS = "http://0.0.0.0:${toString cfg.port}"; + ASPNETCORE_ENVIRONMENT = "Production"; + ConnectionStrings__Default = "Data Source=${cfg.dataDir}/phenix.db"; + } // lib.optionalAttrs (cfg.sessionKey != "") { + SessionKey = cfg.sessionKey; + }; + + postStart = '' + mkdir -p ${cfg.dataDir} + ''; + }; + + users.users.phenix = { + isSystemUser = true; + group = "phenix"; + home = cfg.dataDir; + createHome = true; + }; + + users.groups.phenix = {}; + + services.nginx = lib.mkIf (cfg.domain != "") { + enable = true; + virtualHosts.${cfg.domain} = { + forceSSL = true; + enableACME = true; + locations."/" = { + proxyPass = "http://127.0.0.1:${toString cfg.port}"; + proxyWebsockets = false; + }; + }; + }; + }; + }; + }; }