Initial commit.

This commit is contained in:
qwsdcvghyu89
2026-07-01 00:43:10 +10:00
commit cc16682876
43 changed files with 6813 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "aeqw89-literarium"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "aeqw89_literarium_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json"] }
keyring = "3"
url = "2"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+23
View File
@@ -0,0 +1,23 @@
mod session;
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.invoke_handler(tauri::generate_handler![
session::isLoggedIn,
session::sendGet,
session::sendPost,
session::signIn,
session::logout,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
aeqw89_literarium_lib::run()
}
+249
View File
@@ -0,0 +1,249 @@
//! session.rs — authenticated request layer for the Tauri (Rust) side.
//!
//! The session token lives ONLY here: at rest in the OS keychain, and in an
//! in-memory copy for the lifetime of the process. The webview never receives
//! it — commands return response *data*, never the token itself. Even a script
//! injected into the Blazor frontend can ask Rust to make requests while the app
//! is open, but it cannot read the durable credential or exfiltrate it.
use std::sync::Mutex;
use serde::Serialize;
use tauri::State;
// --- Configuration ---------------------------------------------------------
const VERIFY_URL: &str = "https://main.qwsdcvghyu.com/session/verify";
const SIGNIN_URL: &str = "https://main.qwsdcvghyu.com/session/signin-app/";
/// Only this host (and its subdomains) ever receives the session cookie.
/// `sendGet`/`sendPost` take a URL from the frontend, so without this guard an
/// injected script could call `sendGet("https://evil.com")` and your token would
/// be attached and shipped straight to an attacker. Requests to any other host
/// are rejected before a connection is opened.
const ALLOWED_HOST: &str = "qwsdcvghyu.com";
// Keychain identifiers. SERVICE should be unique to your app (reverse-DNS is
// the convention); ACCOUNT distinguishes this secret from any others you store.
const KEYRING_SERVICE: &str = "com.qwsdcvghyu.literarium";
const KEYRING_ACCOUNT: &str = "session";
// --- Keychain helpers ------------------------------------------------------
fn keyring_entry() -> keyring::Result<keyring::Entry> {
keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT)
}
fn persist_token(token: &str) -> keyring::Result<()> {
keyring_entry()?.set_password(token)
}
fn load_persisted_token() -> Option<String> {
// A missing entry (first launch, or after logout) is the normal "no token"
// case, so any error here just means "not logged in".
keyring_entry().ok()?.get_password().ok()
}
fn clear_persisted_token() -> keyring::Result<()> {
// `delete_credential` is the keyring 3.x name; it was `delete_password` in 2.x.
match keyring_entry()?.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()), // already gone — treat as success
Err(e) => Err(e),
}
}
// --- State -----------------------------------------------------------------
pub struct SessionState {
client: reqwest::Client,
token: Mutex<Option<String>>,
}
impl SessionState {
/// Build the state and load any previously stored token from the keychain.
/// Wire this into your builder with `.manage(session::SessionState::init())`.
pub fn init() -> Self {
SessionState {
client: reqwest::Client::new(),
token: Mutex::new(load_persisted_token()),
}
}
/// Copy the current token out. We never hold the std Mutex across an `.await`
/// (that would risk a deadlock and isn't `Send`-safe), so callers clone the
/// value out first and release the lock immediately.
fn token(&self) -> Option<String> {
self.token.lock().unwrap().clone()
}
fn set_token(&self, value: Option<String>) {
*self.token.lock().unwrap() = value;
}
}
// --- Shared types ----------------------------------------------------------
#[derive(Serialize)]
pub struct HttpResponse {
pub status: u16,
pub body: String,
}
// --- URL guard -------------------------------------------------------------
/// Require https and our own domain before attaching the session cookie.
fn check_url(raw: &str) -> Result<url::Url, String> {
let url = url::Url::parse(raw).map_err(|e| format!("invalid url: {e}"))?;
if url.scheme() != "https" {
return Err("refusing non-https request".into());
}
match url.host_str() {
// The leading dot in the suffix check prevents `evilqwsdcvghyu.com`
// from sneaking past as a fake subdomain.
Some(host)
if host == ALLOWED_HOST || host.ends_with(&format!(".{ALLOWED_HOST}")) =>
{
Ok(url)
}
_ => Err("refusing to send the session cookie to a non-allowlisted host".into()),
}
}
// --- Commands --------------------------------------------------------------
/// True iff a token is stored AND the server confirms it at /session/verify.
/// Any other outcome — no token, a 403, or a transient network failure — reads
/// as `false`. (If you'd rather distinguish "offline" from "logged out", surface
/// the network error as `Err` instead of folding it into `Ok(false)`.)
#[allow(non_snake_case)]
#[tauri::command]
pub async fn isLoggedIn(state: State<'_, SessionState>) -> Result<bool, String> {
let Some(token) = state.token() else {
return Ok(false); // no token: skip the round-trip entirely
};
let resp = state
.client
.get(VERIFY_URL)
.header(reqwest::header::COOKIE, format!("session={token}"))
.send()
.await;
match resp {
Ok(r) => Ok(r.status().as_u16() == 200),
Err(_) => Ok(false),
}
}
/// GET `uri` (must be https + on the allowlisted host), attaching the stored
/// session token as the `session` cookie if one is present. Returns the response
/// status and body — never the token.
#[allow(non_snake_case)]
#[tauri::command]
pub async fn sendGet(
uri: String,
state: State<'_, SessionState>,
) -> Result<HttpResponse, String> {
let url = check_url(&uri)?;
let mut req = state.client.get(url);
if let Some(token) = state.token() {
req = req.header(reqwest::header::COOKIE, format!("session={token}"));
}
let resp = req.send().await.map_err(|e| format!("request failed: {e}"))?;
let status = resp.status().as_u16();
let body = resp.text().await.map_err(|e| format!("failed to read body: {e}"))?;
Ok(HttpResponse { status, body })
}
/// POST `body` (a JSON string serialized on the Blazor side) to `uri` with
/// Content-Type application/json, attaching the `session` cookie if present.
#[allow(non_snake_case)]
#[tauri::command]
pub async fn sendPost(
uri: String,
body: String,
state: State<'_, SessionState>,
) -> Result<HttpResponse, String> {
let url = check_url(&uri)?;
let mut req = state
.client
.post(url)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body);
if let Some(token) = state.token() {
req = req.header(reqwest::header::COOKIE, format!("session={token}"));
}
let resp = req.send().await.map_err(|e| format!("request failed: {e}"))?;
let status = resp.status().as_u16();
let body = resp.text().await.map_err(|e| format!("failed to read body: {e}"))?;
Ok(HttpResponse { status, body })
}
// --- Sign-in / sign-out ----------------------------------------------------
// Not in your list of three, but the keychain has to be populated somehow:
// without these, `isLoggedIn` and `sendGet`/`sendPost` have no token to send.
// Note `signIn` returns Ok(()) — it captures the token and stores it; it does
// NOT hand the raw token back to the webview. (For the same reason, never point
// `sendPost` at the signin endpoint: its 200 body contains the token, which
// would then land in the frontend, defeating the whole arrangement.)
/// Authenticate against /session/signin-app/ and store the returned token in the
/// keychain. The hCaptcha token is solved in the webview and passed in here.
#[tauri::command]
pub async fn signIn(
username: String,
password: String,
captcha: String,
state: State<'_, SessionState>,
) -> Result<(), String> {
let payload = serde_json::json!({
"username": username,
"password": password,
"h-captcha-response": captcha,
});
let resp = state
.client
.post(SIGNIN_URL)
.json(&payload)
.send()
.await
.map_err(|e| format!("request failed: {e}"))?;
let status = resp.status().as_u16();
let value: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("failed to parse response: {e}"))?;
if status == 200 {
let token = value
.get("session")
.and_then(|s| s.as_str())
.ok_or("login succeeded but the response had no `session` field")?
.to_owned();
persist_token(&token).map_err(|e| format!("failed to store token: {e}"))?;
state.set_token(Some(token));
Ok(())
} else {
// Failure responses carry { failed: true, message: string }.
let message = value
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("login failed");
Err(message.to_owned())
}
}
/// Forget the session locally (keychain + in-memory copy).
#[tauri::command]
pub fn logout(state: State<'_, SessionState>) -> Result<(), String> {
state.set_token(None);
clear_persisted_token().map_err(|e| format!("failed to clear token: {e}"))
}
@@ -0,0 +1,36 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "aeqw89-literarium",
"version": "0.1.0",
"identifier": "com.qwsdcvghuy89.literarium",
"build": {
"beforeDevCommand": "dotnet watch run --project src/Aeqw89Literarium.csproj",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "dotnet publish -c release src/Aeqw89Literarium.csproj -o dist",
"frontendDist": "../dist/wwwroot"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "aeqw89-literarium",
"width": 800,
"height": 600
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}