chore: initial commit

This commit is contained in:
Anna 2023-11-12 16:40:46 -05:00
commit 8d7bce8677
Signed by: anna
GPG Key ID: D0943384CD9F87D1
28 changed files with 3201 additions and 0 deletions

5
client/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
bin/
obj/
/packages/
riderModule.iml
/_ReSharper.Caches/

16
client/EorzeaVotes.sln Normal file
View File

@ -0,0 +1,16 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EorzeaVotes", "EorzeaVotes\EorzeaVotes.csproj", "{05A9692E-560E-457C-829B-062E11FD3887}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{05A9692E-560E-457C-829B-062E11FD3887}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{05A9692E-560E-457C-829B-062E11FD3887}.Debug|Any CPU.Build.0 = Debug|Any CPU
{05A9692E-560E-457C-829B-062E11FD3887}.Release|Any CPU.ActiveCfg = Release|Any CPU
{05A9692E-560E-457C-829B-062E11FD3887}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@ -0,0 +1,19 @@
namespace EorzeaVotes;
[Serializable]
internal class BasicQuestion : IQuestion {
/// <inheritdoc/>
public required Guid Id { get; init; }
/// <inheritdoc/>
public required string Date { get; init; }
/// <inheritdoc/>
public required bool Active { get; init; }
/// <inheritdoc/>
public required string Text { get; init; }
/// <inheritdoc/>
public required string[] Answers { get; init; }
}

View File

@ -0,0 +1,30 @@
using Dalamud.Game.Command;
namespace EorzeaVotes;
internal class Commands : IDisposable {
private Plugin Plugin { get; }
private static readonly string[] CommandNames = {
"/eorzeavotes",
"/ev",
};
internal Commands(Plugin plugin) {
this.Plugin = plugin;
foreach (var name in CommandNames) {
this.Plugin.CommandManager.AddHandler(name, new CommandInfo(this.Handler));
}
}
public void Dispose() {
foreach (var name in CommandNames) {
this.Plugin.CommandManager.RemoveHandler(name);
}
}
private void Handler(string command, string arguments) {
this.Plugin.Ui.Visible ^= true;
}
}

View File

@ -0,0 +1,45 @@
using Dalamud.Configuration;
namespace EorzeaVotes;
[Serializable]
internal class Configuration : IPluginConfiguration {
public int Version { get; set; } = 1;
public bool OnBoarded;
public Gender? Gender = null;
public DateOnly? BirthDate = null;
public bool RaceClanGender = false;
public bool HomeWorld = false;
public int YearStartedPlaying;
public Mbti? MyersBriggs = null;
}
// For statistical breakdowns, a text box is not useful. Other not included to
// not "other" people.
internal enum Gender {
Female,
NonBinary,
Male,
}
// ReSharper disable IdentifierTypo
internal enum Mbti {
Enfj,
Enfp,
Entj,
Entp,
Esfj,
Esfp,
Estj,
Estp,
Infj,
Infp,
Intj,
Intp,
Isfj,
Isfp,
Istj,
Istp,
}
// ReSharper enable IdentifierTypo

View File

@ -0,0 +1,48 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Version>1.0.0</Version>
<TargetFramework>net7.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
<ProduceReferenceAssembly>false</ProduceReferenceAssembly>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<LangVersion>preview</LangVersion>
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Configurations>Debug;Release</Configurations>
<Platforms>x64</Platforms>
</PropertyGroup>
<PropertyGroup>
<DalamudLibPath>$(AppData)\XIVLauncher\addon\Hooks\dev</DalamudLibPath>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))'">
<DalamudLibPath>$(DALAMUD_HOME)</DalamudLibPath>
</PropertyGroup>
<PropertyGroup Condition="'$(IsCI)' == 'true'">
<DalamudLibPath>$(HOME)/dalamud</DalamudLibPath>
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
</PropertyGroup>
<ItemGroup>
<Reference Include="Dalamud">
<HintPath>$(DalamudLibPath)\Dalamud.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="ImGui.NET">
<HintPath>$(DalamudLibPath)\ImGui.NET.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Newtonsoft.Json">
<HintPath>$(DalamudLibPath)\Newtonsoft.Json.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>

View File

@ -0,0 +1,27 @@
namespace EorzeaVotes;
[Serializable]
internal class FullQuestion : IQuestion {
/// <inheritdoc/>
public required Guid Id { get; init; }
/// <inheritdoc/>
public required string Date { get; init; }
/// <inheritdoc/>
public required bool Active { get; init; }
/// <inheritdoc/>
public required string Text { get; init; }
/// <inheritdoc/>
public required string[] Answers { get; init; }
/// <summary>
/// A map of country to a list of responses to the question.
/// <br/><br/>
/// The array is the number of respondents who chose the answer at that
/// index.
/// </summary>
public required Dictionary<string, uint[]> Responses { get; init; }
}

View File

@ -0,0 +1,19 @@
namespace EorzeaVotes;
internal static class GenderExt {
internal static string Name(this Gender? gender) {
return gender switch {
null => "Unspecified",
_ => gender.Value.Name(),
};
}
internal static string Name(this Gender gender) {
return gender switch {
Gender.Female => "Female",
Gender.NonBinary => "Non-binary",
Gender.Male => "Male",
_ => throw new ArgumentException("unexpected gender", nameof(gender)),
};
}
}

View File

@ -0,0 +1,80 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace EorzeaVotes;
[JsonConverter(typeof(QuestionConverter))]
internal interface IQuestion {
/// <summary>
/// The question's unique ID.
/// </summary>
public Guid Id { get; init; }
/// <summary>
/// The date at which the question was published.
/// </summary>
public string Date { get; init; }
/// <summary>
/// If the question is currently the active question for voting.
/// </summary>
public bool Active { get; init; }
/// <summary>
/// The body text of the question.
/// </summary>
public string Text { get; init; }
/// <summary>
/// A list of possible answers to the question.
/// </summary>
public string[] Answers { get; init; }
}
internal class QuestionConverter : JsonConverter {
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) {
throw new NotImplementedException();
}
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) {
if (reader.TokenType == JsonToken.Null) {
return null;
}
// load as object
var obj = JObject.Load(reader);
// get the type key from the object
var typeToken = obj.GetValue("type");
if (typeToken == null) {
throw new Exception("missing type key");
}
// parse as a string, then remove the key from the object
var type = typeToken.Value<string>();
typeToken.Remove();
// determine the type to deserialise
var actualType = type switch {
"basic" => typeof(BasicQuestion),
"full" => typeof(FullQuestion),
_ => throw new Exception("invalid question type"),
};
// resolve the contract and create a default question
var contract = serializer.ContractResolver.ResolveContract(actualType);
var question = existingValue ?? contract.DefaultCreator!();
// populate the default question
using var subReader = obj.CreateReader();
serializer.Populate(subReader, question);
return question;
}
public override bool CanConvert(Type objectType) {
return objectType == typeof(IQuestion)
|| objectType == typeof(BasicQuestion)
|| objectType == typeof(FullQuestion);
}
}

View File

@ -0,0 +1,108 @@
using Dalamud.Interface;
using ImGuiNET;
namespace EorzeaVotes;
internal static class ImGuiHelper {
internal static void Help(string text) {
ImGui.SameLine();
ImGui.PushFont(UiBuilder.IconFont);
try {
ImGui.TextUnformatted(FontAwesomeIcon.QuestionCircle.ToIconString());
} finally {
ImGui.PopFont();
}
Tooltip(text);
}
internal static void Tooltip(string text) {
if (!ImGui.IsItemHovered()) {
return;
}
ImGui.BeginTooltip();
using var end = new OnDispose(ImGui.EndTooltip);
ImGui.PushTextWrapPos(ImGui.CalcTextSize("m").X * 60);
using var pop = new OnDispose(ImGui.PopTextWrapPos);
ImGui.TextUnformatted(text);
}
internal static bool EnumDropDownVertical<T>(string label, string? nullLabel, ref T? value, string? helpTooltip = null, Func<T?, string>? nameFunction = null)
where T : struct, Enum {
const string defaultNullLabel = "None";
var changed = false;
ImGui.TextUnformatted(label);
if (helpTooltip != null) {
Help(helpTooltip);
}
ImGui.SetNextItemWidth(-1);
if (!ImGui.BeginCombo($"##{label}", GetName(value))) {
if (nullLabel != null && ImGui.Selectable(nullLabel, !value.HasValue)) {
value = null;
changed = true;
}
foreach (var variant in Enum.GetValues<T>()) {
if (ImGui.Selectable(GetName(variant), value.HasValue && value.Value.Equals(variant))) {
value = variant;
changed = true;
}
}
}
return changed;
string GetName(T? t) {
return nameFunction == null
? t == null
? nullLabel ?? defaultNullLabel
: Enum.GetName(t.Value) ?? "???"
: nameFunction(t);
}
}
internal static bool InputDateVertical(string label, ref string input, ref DateOnly? date) {
ImGui.TextUnformatted(label);
ImGui.SetNextItemWidth(-1);
if (!ImGui.InputText($"##{label}", ref input, 10)) {
return false;
}
var match = Regexes.DateRegex().Match(input);
if (!match.Success) {
return false;
}
var success = true;
success &= ushort.TryParse(match.Groups[1].Value, out var year);
success &= byte.TryParse(match.Groups[2].Value, out var month);
success &= byte.TryParse(match.Groups[3].Value, out var day);
if (!success) {
return false;
}
var now = DateOnly.FromDateTime(DateTime.UtcNow);
var valid = year >= now.Year - 115 && year <= now.Year;
// day and month will be caught be the ctor
if (!valid) {
return false;
}
try {
date = new DateOnly(year, month, day);
} catch {
return false;
}
return true;
}
}

View File

@ -0,0 +1,19 @@
namespace EorzeaVotes;
internal class OnDispose : IDisposable {
private Action Action { get; }
private bool _disposed;
internal OnDispose(Action action) {
this.Action = action;
}
public void Dispose() {
if (this._disposed) {
return;
}
this._disposed = true;
this.Action();
}
}

View File

@ -0,0 +1,68 @@
using System.Diagnostics;
using Dalamud.IoC;
using Dalamud.Plugin;
using Dalamud.Plugin.Services;
namespace EorzeaVotes;
public class Plugin : IDalamudPlugin {
internal const string Name = "Eorzea Votes";
[PluginService]
internal DalamudPluginInterface Interface { get; } = null!;
[PluginService]
internal IClientState ClientState { get; } = null!;
[PluginService]
internal ICommandManager CommandManager { get; } = null!;
[PluginService]
internal IFramework Framework { get; } = null!;
internal Configuration Config { get; }
internal QuestionChecker Checker { get; }
internal PluginUi Ui { get; }
private Commands Commands { get; }
private bool _checkNow = true;
private Stopwatch Stopwatch { get; } = Stopwatch.StartNew();
public Plugin() {
this.Config = this.Interface.GetPluginConfig() as Configuration ?? new Configuration();
this.Checker = new QuestionChecker(this);
this.Ui = new PluginUi(this);
this.Commands = new Commands(this);
this.Framework.Update += this.RunChecker;
this.ClientState.Login += this.Login;
}
public void Dispose() {
this.ClientState.Login -= this.Login;
this.Framework.Update -= this.RunChecker;
this.Commands.Dispose();
this.Ui.Dispose();
this.Checker.Dispose();
}
internal void SaveConfig() {
this.Interface.SavePluginConfig(this.Config);
}
private void RunChecker(IFramework framework) {
if (!this._checkNow && this.Stopwatch.Elapsed < TimeSpan.FromMinutes(5)) {
return;
}
this._checkNow = false;
this.Stopwatch.Restart();
Task.Run(async () => await this.Checker.Check());
}
private void Login() {
this.Checker.OpenIfNew();
}
}

View File

@ -0,0 +1,183 @@
using System.Numerics;
using Dalamud.Interface.Utility;
using ImGuiNET;
namespace EorzeaVotes;
internal class PluginUi : IDisposable {
private Plugin Plugin { get; }
internal bool Visible { get; set; }
private string _dateScratch = string.Empty;
internal PluginUi(Plugin plugin) {
this.Plugin = plugin;
this.Plugin.Interface.UiBuilder.Draw += this.Draw;
}
public void Dispose() {
this.Plugin.Interface.UiBuilder.Draw -= this.Draw;
}
private void Draw() {
if (!this.Visible) {
return;
}
using var end = new OnDispose(ImGui.End);
ImGui.SetNextWindowSize(new Vector2(512, 768), ImGuiCond.FirstUseEver);
if (!ImGui.Begin(Plugin.Name)) {
return;
}
ImGui.PushTextWrapPos();
using var pop = new OnDispose(ImGui.PopTextWrapPos);
if (this.Plugin.Config.OnBoarded) {
this.DrawMain();
} else {
this.DrawOnboarding();
}
}
private bool DrawOptionalQuestions() {
var anyChanged = false;
anyChanged |= ImGuiHelper.EnumDropDownVertical(
"I most identify as",
"Unspecified",
ref this.Plugin.Config.Gender,
helpTooltip: "This does not encompass the entire spectrum of experiencing gender. Please feel free to skip this question if you do not identify with any of the choices. It is hard and potentially privacy-invasive to accept all possible answers here for use in statistical breakdowns.",
nameFunction: GenderExt.Name
);
anyChanged |= ImGuiHelper.InputDateVertical(
"I was born on (yyyy-mm-dd)",
ref this._dateScratch,
ref this.Plugin.Config.BirthDate
);
if (this.Plugin.Config.BirthDate != null) {
ImGui.TextUnformatted(this.Plugin.Config.BirthDate.Value.ToString("D"));
}
anyChanged |= ImGui.Checkbox(
"I would like to send the race, clan, and gender of the character I'm playing when I vote",
ref this.Plugin.Config.RaceClanGender
);
anyChanged |= ImGui.Checkbox(
"I would like to send the home world of the character I'm playing when I vote",
ref this.Plugin.Config.HomeWorld
);
ImGui.TextUnformatted("I started playing FFXIV in (year)");
ImGui.SetNextItemWidth(-1);
if (ImGui.InputInt("##I started playing FFXIV in", ref this.Plugin.Config.YearStartedPlaying)) {
this.Plugin.Config.YearStartedPlaying = Math.Clamp(this.Plugin.Config.YearStartedPlaying, 2010, DateTime.UtcNow.Year);
anyChanged = true;
}
anyChanged |= ImGuiHelper.EnumDropDownVertical(
"My Myers-Briggs Type Indicator is",
"Unspecified",
ref this.Plugin.Config.MyersBriggs,
helpTooltip: "The MBTI is pseudoscience, but this is a fun question.",
nameFunction: mbti => mbti == null
? "Unspecified"
: (Enum.GetName(mbti.Value) ?? "???").ToUpperInvariant()
);
return anyChanged;
}
private void DrawOnboarding() {
ImGuiHelpers.CenteredText($"Welcome to {Plugin.Name}!");
ImGui.TextUnformatted("This plugin allows you to vote on lighthearted opinion polls and then see the results. There will be one question per day with new questions available at midnight UTC.");
ImGui.Spacing();
ImGui.TextUnformatted("After voting (or after the question is closed), you can see the results. There are also statistical breakdowns of the votes based on volunteered information.");
ImGui.Spacing();
ImGui.TextUnformatted("Since this is the first time you have opened this plugin, you will be shown some optional questions below. You may choose to answer none, some, or all of the questions. Your responses will be sent with your votes to enable further statistical breakdowns. The country associated with your IP address is always sent, so do not vote if you are uncomfortable with that.");
ImGui.Spacing();
ImGuiHelpers.CenteredText("Optional questions");
var anyChanged = this.DrawOptionalQuestions();
ImGui.Spacing();
if (ImGui.Button("Done")) {
this.Plugin.Config.OnBoarded = true;
anyChanged = true;
}
if (anyChanged) {
this.Plugin.SaveConfig();
}
}
private void DrawMain() {
ImGuiHelpers.CenteredText("Active question");
var active = this.Plugin.Checker.Questions.FirstOrDefault(q => q.Active);
if (active == null) {
ImGui.TextUnformatted("There is no active question at the moment.");
} else {
ImGui.TextUnformatted(active.Text);
for (var i = 0; i < active.Answers.Length; i++) {
if (i != 0) {
ImGui.SameLine();
}
if (ImGui.Button(active.Answers[i])) {
// vote
}
}
if (active is FullQuestion full) {
this.DrawResponses(full);
}
}
ImGui.Separator();
ImGuiHelpers.CenteredText("Inactive questions");
foreach (var question in this.Plugin.Checker.Questions) {
if (question.Active || question is not FullQuestion full) {
continue;
}
ImGui.TextUnformatted(full.Text);
this.DrawResponses(full);
ImGui.Spacing();
}
}
private void DrawResponses(FullQuestion question) {
var totalResponses = new uint[question.Answers.Length];
foreach (var responses in question.Responses.Values) {
for (var i = 0; i < totalResponses.Length && i < responses.Length; i++) {
totalResponses[i] += responses[i];
}
}
var sum = totalResponses.Aggregate(0u, (agg, val) => agg + val);
if (!ImGui.BeginTable($"##{question.Text}-total-responses", 3)) {
return;
}
using var end2 = new OnDispose(ImGui.EndTable);
for (var i = 0; i < totalResponses.Length && i < question.Answers.Length; i++) {
ImGui.TableNextRow();
ImGui.TextUnformatted(question.Answers[i]);
ImGui.TableNextColumn();
ImGui.TextUnformatted($"{(float) totalResponses[i] / sum * 100:N2}%");
ImGui.TableNextColumn();
ImGui.TextUnformatted($"{totalResponses[i]}:N0");
}
}
}

View File

@ -0,0 +1,52 @@
using System.Net.Http.Headers;
using Newtonsoft.Json;
namespace EorzeaVotes;
internal class QuestionChecker : IDisposable {
private Plugin Plugin { get; }
private HttpClient Http { get; }
internal IReadOnlyList<IQuestion> Questions => this._questions;
private List<IQuestion> _questions = new();
private Guid _lastSeenActive = Guid.Empty;
internal QuestionChecker(Plugin plugin) {
this.Plugin = plugin;
this.Http = new HttpClient {
DefaultRequestHeaders = {
UserAgent = {
new ProductInfoHeaderValue("EorzeaVotes", typeof(QuestionChecker).Assembly.GetName().Version?.ToString(3) ?? "???"),
},
},
};
}
public void Dispose() {
this.Http.Dispose();
}
internal async Task Check() {
var json = await this.Http.GetStringAsync("https://ev.anna.lgbt/questions");
this._questions = JsonConvert.DeserializeObject<List<IQuestion>>(json) ?? new List<IQuestion>();
this.OpenIfNew();
}
internal void OpenIfNew() {
if (!this.Plugin.ClientState.IsLoggedIn) {
return;
}
var active = this.Questions.FirstOrDefault(q => q.Active);
if (active is not BasicQuestion) {
return;
}
if (this._lastSeenActive != active.Id) {
this.Plugin.Ui.Visible ^= true;
}
this._lastSeenActive = active.Id;
}
}

View File

@ -0,0 +1,8 @@
using System.Text.RegularExpressions;
namespace EorzeaVotes;
internal static partial class Regexes {
[GeneratedRegex(@"(\d{4})-(\d{2})-(\d{2})", RegexOptions.Compiled)]
internal static partial Regex DateRegex();
}

View File

@ -0,0 +1,6 @@
{
"version": 1,
"dependencies": {
"net7.0-windows7.0": {}
}
}

16
server/.cargo/config.toml Normal file
View File

@ -0,0 +1,16 @@
[build]
rustflags = ['--cfg', 'uuid_unstable']
[target.x86_64-unknown-linux-gnu]
rustflags = [
"-C",
"link-arg=-fuse-ld=/usr/bin/mold",
"--cfg",
"uuid_unstable",
]
[target.aarch64-unknown-linux-musl]
rustflags = [
"--cfg",
"uuid_unstable",
]

1
server/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

2045
server/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

19
server/Cargo.toml Normal file
View File

@ -0,0 +1,19 @@
[package]
name = "server"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1"
axum = { version = "0.6", features = ["json"] }
blake3 = { version = "1", features = ["traits-preview"] }
chrono = { version = "0.4", features = ["serde"]}
rand = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.7", default-features = false, features = ["runtime-tokio", "postgres", "macros", "migrate", "chrono", "uuid"] }
tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "macros"] }
toml = "0.8"
uuid = { version = "1", features = ["serde", "v7"] }

9
server/config.toml Normal file
View File

@ -0,0 +1,9 @@
[server]
address = '127.0.0.1:11111'
[database]
host = 'localhost'
port = 11112
name = 'eorzeavotes'
username = 'postgres'
password = 'uwu'

View File

@ -0,0 +1,3 @@
drop table responses;
drop table questions;
drop table users;

View File

@ -0,0 +1,20 @@
create table users (
id uuid not null primary key,
api_key_hash bytea not null
);
create table questions (
id uuid not null primary key,
publish_date timestamp with time zone not null,
question_text text not null,
answers text[] not null
);
create table responses (
user_id uuid references users (id) on delete set null,
question_id uuid not null references questions (id) on delete cascade,
answer smallint not null,
country text not null,
primary key (user_id, question_id)
);

34
server/src/app_error.rs Normal file
View File

@ -0,0 +1,34 @@
use axum::{response::{IntoResponse, Response}, http::StatusCode, Json};
use serde::Serialize;
pub type AppResult<T, E = AppError> = std::result::Result<T, E>;
// Make our own error that wraps `anyhow::Error`.
pub struct AppError(pub anyhow::Error);
#[derive(Serialize)]
struct ErrorResponse {
error: String,
}
// Tell axum how to convert `AppError` into a response.
impl IntoResponse for AppError {
fn into_response(self) -> Response {
(
StatusCode::BAD_REQUEST,
Json(ErrorResponse { error: format!("{:#}", self.0) }),
)
.into_response()
}
}
// This enables using `?` on functions that return `Result<_, anyhow::Error>` to turn them into
// `Result<_, AppError>`. That way you don't need to do that manually.
impl<E> From<E> for AppError
where
E: Into<anyhow::Error>,
{
fn from(err: E) -> Self {
Self(err.into())
}
}

48
server/src/auth.rs Normal file
View File

@ -0,0 +1,48 @@
use anyhow::Context;
use axum::http::request::Parts;
use axum::{extract::FromRequestParts, async_trait};
use uuid::Uuid;
use crate::ArcState;
use crate::app_error::AppError;
pub struct User {
pub id: Uuid,
pub country: String,
}
#[async_trait]
impl FromRequestParts<ArcState> for User {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &ArcState) -> Result<Self, Self::Rejection> {
let token = parts.headers
.get("x-api-key")
.context("missing x-api-key header")?
.to_str()
.context("x-api-key is not a valid utf-8 string")?;
let hashed = {
let mut hasher = blake3::Hasher::new();
hasher.update(token.as_bytes());
hasher.finalize().as_bytes().to_vec()
};
let country = parts.headers
.get("cf-ipcountry")
.and_then(|val| val.to_str().ok())
.context("missing cloudflare country header")?
.to_string();
let id = sqlx::query_scalar!(
// language=postgresql
"select id from users where api_key_hash = $1",
hashed,
)
.fetch_optional(&state.pool)
.await
.context("could not check database")?
.context("no such api key")?;
Ok(User { id, country })
}
}

21
server/src/config.rs Normal file
View File

@ -0,0 +1,21 @@
use serde::Deserialize;
#[derive(Deserialize)]
pub struct Config {
pub server: Server,
pub database: Database,
}
#[derive(Deserialize)]
pub struct Server {
pub address: String,
}
#[derive(Deserialize)]
pub struct Database {
pub host: String,
pub port: u16,
pub name: String,
pub username: String,
pub password: String,
}

212
server/src/main.rs Normal file
View File

@ -0,0 +1,212 @@
mod config;
mod question;
mod app_error;
mod auth;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use anyhow::{Result, Context};
use axum::http::StatusCode;
use axum::routing::{get, post};
use axum::{Router, Json};
use axum::extract::State;
use rand::distributions::{Alphanumeric, DistString};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
use uuid::Uuid;
use crate::app_error::AppResult;
use crate::auth::User;
use crate::config::Config;
use crate::question::{FullQuestion, BasicQuestion, Question};
#[tokio::main]
async fn main() -> Result<()> {
let config: Config = {
let t = std::fs::read_to_string("./config.toml")
.context("could not read config.toml")?;
toml::from_str(&t)
.context("could not deserialize config file")?
};
let options = PgConnectOptions::new()
.host(&config.database.host)
.port(config.database.port)
.database(&config.database.name)
.username(&config.database.username)
.password(&config.database.password);
let pool = PgPoolOptions::new()
.connect_with(options)
.await
.context("could not connect to database")?;
sqlx::migrate!()
.run(&pool)
.await?;
let address: SocketAddr = config.server.address.parse()?;
let app = Router::new()
.route(
"/user",
post(create_user)
.delete(delete_user)
)
.route("/questions", get(get_data))
.route("/vote", post(vote))
// .route("/question")
.with_state(Arc::new(AppState {
config,
pool,
}));
axum::Server::bind(&address)
.serve(app.into_make_service())
.await?;
Ok(())
}
pub type ArcState = Arc<AppState>;
pub struct AppState {
pub config: Config,
pub pool: PgPool,
}
#[derive(Serialize)]
struct CreateUserResponse {
user_id: String,
api_key: String,
}
async fn create_user(
state: State<ArcState>,
) -> AppResult<Json<CreateUserResponse>> {
let id = Uuid::now_v7();
let api_key = Alphanumeric.sample_string(&mut rand::thread_rng(), 32);
let api_key_hash = {
let mut hasher = blake3::Hasher::new();
hasher.update(api_key.as_bytes());
hasher.finalize().as_bytes().to_vec()
};
sqlx::query!(
// language=postgresql
"insert into users (id, api_key_hash) values ($1, $2)",
id,
api_key_hash,
)
.execute(&state.pool)
.await?;
Ok(Json(CreateUserResponse {
user_id: id.simple().to_string(),
api_key,
}))
}
async fn delete_user(
state: State<ArcState>,
user: User,
) -> AppResult<StatusCode> {
sqlx::query!(
// language=postgresql
"delete from users where id = $1",
user.id,
)
.execute(&state.pool)
.await?;
Ok(StatusCode::NO_CONTENT)
}
async fn get_data(
state: State<ArcState>,
user: User,
) -> AppResult<Json<Vec<Question>>> {
let questions = sqlx::query!(
// language=postgresql
r#"
select
q.*,
current_timestamp <= q.publish_date + interval '1 day' and current_timestamp > q.publish_date as "active!",
exists(select 1 from responses r where r.question_id = q.id and r.user_id = $1) as "voted!"
from questions q
where q.publish_date <= current_timestamp
order by q.publish_date desc
"#,
user.id,
)
.fetch_all(&state.pool)
.await?;
let mut parsed = Vec::with_capacity(questions.len());
for question in questions {
let mut responses: HashMap<String, Vec<u64>> = HashMap::new();
let raw_responses = sqlx::query!(
// language=postgresql
"select r.country, r.answer, count(r.*) from responses r where r.question_id = $1 group by r.country, r.answer",
question.id,
)
.fetch_all(&state.pool)
.await?;
for response in raw_responses {
let answers = responses.entry(response.country.clone())
.or_insert_with(|| vec![0; question.answers.len()]);
let sum = answers.get_mut(response.answer as usize)
.context("bug: missing answer sum")?;
*sum = response.count.map(|c| c as u64).unwrap_or(0);
}
let basic = BasicQuestion {
id: question.id,
date: question.publish_date,
active: question.active,
text: question.question_text,
answers: question.answers,
};
if question.voted || !question.active {
parsed.push(FullQuestion {
basic,
responses,
}.into());
} else {
parsed.push(basic.into());
}
}
Ok(Json(parsed))
}
#[derive(Deserialize)]
struct VoteRequest {
question_id: Uuid,
answer: u16,
}
async fn vote(
state: State<ArcState>,
user: User,
req: Json<VoteRequest>,
) -> AppResult<StatusCode> {
sqlx::query!(
// language=postgresql
"insert into responses (question_id, user_id, answer, country) values ($1, $2, $3, $4)",
req.question_id,
user.id,
req.answer as i16,
user.country,
)
.execute(&state.pool)
.await
.map_err(|_| anyhow::anyhow!("you have either already voted or the question id is invalid"))?;
Ok(StatusCode::NO_CONTENT)
}

40
server/src/question.rs Normal file
View File

@ -0,0 +1,40 @@
use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::Serialize;
use uuid::Uuid;
#[derive(Serialize)]
pub struct BasicQuestion {
pub id: Uuid,
pub date: DateTime<Utc>,
pub active: bool,
pub text: String,
pub answers: Vec<String>,
}
#[derive(Serialize)]
pub struct FullQuestion {
#[serde(flatten)]
pub basic: BasicQuestion,
pub responses: HashMap<String, Vec<u64>>,
}
#[derive(Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Question {
Basic(BasicQuestion),
Full(FullQuestion),
}
impl From<BasicQuestion> for Question {
fn from(q: BasicQuestion) -> Self {
Self::Basic(q)
}
}
impl From<FullQuestion> for Question {
fn from(q: FullQuestion) -> Self {
Self::Full(q)
}
}