basic web app showing the list of all users
This commit is contained in:
parent
e0a0456402
commit
da91785390
6 changed files with 1166 additions and 26 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,4 +1,4 @@
|
||||||
/target
|
/target
|
||||||
classification.json
|
classification.json
|
||||||
model.json
|
db.json
|
||||||
api_token
|
api_token
|
||||||
|
|
952
Cargo.lock
generated
952
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -15,3 +15,7 @@ anyhow = "1.0.93"
|
||||||
bayespam = "1.1.0"
|
bayespam = "1.1.0"
|
||||||
serde_json = "1.0.133"
|
serde_json = "1.0.133"
|
||||||
rand = "0.8.5"
|
rand = "0.8.5"
|
||||||
|
actix-web = "4"
|
||||||
|
tera = "1"
|
||||||
|
lazy_static = "1.5.0"
|
||||||
|
actix-files = "0.6.6"
|
||||||
|
|
126
src/main.rs
126
src/main.rs
|
@ -7,6 +7,10 @@ use std::path::Path;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::{BufReader, BufWriter};
|
use std::io::{BufReader, BufWriter};
|
||||||
use rand::prelude::*;
|
use rand::prelude::*;
|
||||||
|
use actix_web::{get, post, web, App, HttpResponse, HttpServer, Responder, HttpRequest};
|
||||||
|
use tera::Tera;
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq)]
|
#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq)]
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
|
@ -37,8 +41,9 @@ struct UserId(i64);
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
struct UserData {
|
struct UserData {
|
||||||
// login: String,
|
login: String,
|
||||||
email: String,
|
email: String,
|
||||||
|
full_name: Option<String>,
|
||||||
location: Option<String>,
|
location: Option<String>,
|
||||||
website: Option<String>,
|
website: Option<String>,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
|
@ -188,10 +193,10 @@ async fn get_users_data(forge: &Forgejo) -> anyhow::Result<HashMap<UserId, UserD
|
||||||
eprintln!("WARN: user with no id");
|
eprintln!("WARN: user with no id");
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
// let Some(login) = user.login else {
|
let Some(login) = user.login else {
|
||||||
// eprintln!("WARN: missing login for user {id}");
|
eprintln!("WARN: missing login for user {id}");
|
||||||
// continue;
|
continue;
|
||||||
// };
|
};
|
||||||
|
|
||||||
// TODO: fetch those from the admin API instead
|
// TODO: fetch those from the admin API instead
|
||||||
let Some(email) = user.email else {
|
let Some(email) = user.email else {
|
||||||
|
@ -202,8 +207,9 @@ async fn get_users_data(forge: &Forgejo) -> anyhow::Result<HashMap<UserId, UserD
|
||||||
data.insert(
|
data.insert(
|
||||||
UserId(id),
|
UserId(id),
|
||||||
UserData {
|
UserData {
|
||||||
// login,
|
login,
|
||||||
email,
|
email,
|
||||||
|
full_name: user.full_name,
|
||||||
location: user.location,
|
location: user.location,
|
||||||
website: user.website,
|
website: user.website,
|
||||||
description: user.description,
|
description: user.description,
|
||||||
|
@ -281,10 +287,9 @@ async fn get_users_data(forge: &Forgejo) -> anyhow::Result<HashMap<UserId, UserD
|
||||||
Ok(data)
|
Ok(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
async fn load_db() -> anyhow::Result<(Db, Classifier)> {
|
||||||
async fn main() -> anyhow::Result<()> {
|
|
||||||
let model_path = Path::new("model.json");
|
let model_path = Path::new("model.json");
|
||||||
let mut classifier = if model_path.is_file() {
|
let classifier = if model_path.is_file() {
|
||||||
Classifier::new_from_pre_trained(&mut File::open(model_path)?)?
|
Classifier::new_from_pre_trained(&mut File::open(model_path)?)?
|
||||||
} else {
|
} else {
|
||||||
Classifier::new()
|
Classifier::new()
|
||||||
|
@ -299,7 +304,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let db_path = Path::new("db.json");
|
let db_path = Path::new("db.json");
|
||||||
let mut db = if db_path.is_file() {
|
let db: Db = if db_path.is_file() {
|
||||||
let file = File::open(db_path)?;
|
let file = File::open(db_path)?;
|
||||||
serde_json::from_reader(BufReader::new(file))?
|
serde_json::from_reader(BufReader::new(file))?
|
||||||
} else {
|
} else {
|
||||||
|
@ -313,25 +318,24 @@ async fn main() -> anyhow::Result<()> {
|
||||||
|
|
||||||
let file = File::create(db_path)?;
|
let file = File::create(db_path)?;
|
||||||
serde_json::to_writer(BufWriter::new(file), &db)?;
|
serde_json::to_writer(BufWriter::new(file), &db)?;
|
||||||
|
|
||||||
db
|
db
|
||||||
};
|
};
|
||||||
|
|
||||||
println!("got {} users", db.users.len());
|
Ok((db, classifier))
|
||||||
|
}
|
||||||
|
|
||||||
let mut users: Vec<_> =
|
fn unclassified_users<'a>(db: &'a Db, classifier: &Classifier) ->
|
||||||
db.users.iter()
|
Vec<(&'a UserId, &'a UserData)>
|
||||||
.filter_map(
|
{
|
||||||
|(user_id, user)|
|
db.users
|
||||||
if db.classification.contains_key(&user_id) {
|
.iter()
|
||||||
None
|
.filter(|(user_id, _)| ! db.classification.contains_key(&user_id))
|
||||||
} else {
|
.collect()
|
||||||
let text = db.text.get(&user_id).unwrap();
|
}
|
||||||
let score = classifier.score(text);
|
|
||||||
Some((user_id, user, text, score))
|
/*
|
||||||
}
|
async fn main_() -> anyhow::Result<()> {
|
||||||
)
|
println!("got {} users", db.users.len());
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut rng = rand::thread_rng();
|
let mut rng = rand::thread_rng();
|
||||||
users.shuffle(&mut rng);
|
users.shuffle(&mut rng);
|
||||||
|
@ -375,3 +379,75 @@ async fn main() -> anyhow::Result<()> {
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
pub static ref TEMPLATES: Tera = {
|
||||||
|
match Tera::new("templates/**/*.html") {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
println!("Parsing error(s): {}", e);
|
||||||
|
::std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppState {
|
||||||
|
db: Mutex<Db>,
|
||||||
|
classifier: Mutex<Classifier>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/")]
|
||||||
|
async fn index(data: web::Data<AppState>) -> impl Responder {
|
||||||
|
eprintln!("GET /");
|
||||||
|
|
||||||
|
let db = &data.db.lock().unwrap();
|
||||||
|
let classifier = &data.classifier.lock().unwrap();
|
||||||
|
|
||||||
|
eprintln!("compute unclassified users");
|
||||||
|
|
||||||
|
let users: Vec<&UserData> =
|
||||||
|
unclassified_users(db, classifier).into_iter().map(|(_id, u)| u).collect();
|
||||||
|
|
||||||
|
let mut context = tera::Context::new();
|
||||||
|
|
||||||
|
eprintln!("insert users into tera context");
|
||||||
|
|
||||||
|
context.insert("users", &users);
|
||||||
|
eprintln!("rendering template...");
|
||||||
|
let page = TEMPLATES.render("index.html", &context).unwrap();
|
||||||
|
eprintln!("done");
|
||||||
|
HttpResponse::Ok().body(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/")]
|
||||||
|
async fn apply(data: web::Data<AppState>, req: web::Form<HashMap<String, String>>) -> impl Responder {
|
||||||
|
println!("{:#?}", req);
|
||||||
|
HttpResponse::SeeOther().insert_header(("Location", "/")).finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::main]
|
||||||
|
async fn main() -> std::io::Result<()> {
|
||||||
|
let (db, classifier) = load_db().await.unwrap(); // FIXME
|
||||||
|
|
||||||
|
println!("Done loading DB");
|
||||||
|
|
||||||
|
let st = web::Data::new(AppState {
|
||||||
|
db: Mutex::new(db),
|
||||||
|
classifier: Mutex::new(classifier),
|
||||||
|
});
|
||||||
|
|
||||||
|
println!("Launching web server at http://127.0.0.1:8080...");
|
||||||
|
|
||||||
|
HttpServer::new(move || {
|
||||||
|
App::new()
|
||||||
|
.service(actix_files::Files::new("/static/", "./static"))
|
||||||
|
.app_data(st.clone())
|
||||||
|
.service(index)
|
||||||
|
.service(apply)
|
||||||
|
})
|
||||||
|
.bind(("127.0.0.1", 8080))?
|
||||||
|
.run()
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
98
templates/index.html
Normal file
98
templates/index.html
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
{% import "macros.html" as macros %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
||||||
|
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
||||||
|
<title>Forgejo Spam Admin</title>
|
||||||
|
<!-- Font Awesome -->
|
||||||
|
<!-- <link -->
|
||||||
|
<!-- rel="stylesheet" -->
|
||||||
|
<!-- href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" -->
|
||||||
|
<!-- /> -->
|
||||||
|
<!-- Google Fonts Roboto -->
|
||||||
|
<!-- <link -->
|
||||||
|
<!-- rel="stylesheet" -->
|
||||||
|
<!-- href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700;900&display=swap" -->
|
||||||
|
<!-- /> -->
|
||||||
|
<!-- MDB -->
|
||||||
|
<!-- <link rel="stylesheet" href="css/mdb.min.css" /> -->
|
||||||
|
</head>
|
||||||
|
<style>
|
||||||
|
.flex-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<body>
|
||||||
|
<form method="post">
|
||||||
|
<input type="submit" value="Apply"/>
|
||||||
|
<div class="flex-wrapper">
|
||||||
|
|
||||||
|
{% for user in users %}
|
||||||
|
<div class="user">
|
||||||
|
<div class="user-classification">
|
||||||
|
<input type="checkbox" name="{{user.login}}" style="scale: 1.2"/>
|
||||||
|
</div>
|
||||||
|
<div class="user-card">
|
||||||
|
<div class="user-name">
|
||||||
|
<div>{{ user.login }}</div>
|
||||||
|
{%- if user.full_name %}<div>({{ user.full_name }})</div>{% endif -%}
|
||||||
|
</div>
|
||||||
|
<div class="user-info">
|
||||||
|
{%- if user.location %}<div>[L] {{ user.location }}</div>{% endif -%}
|
||||||
|
{%- if user.website %}<div>[W] {{ user.website }}</div>{% endif -%}
|
||||||
|
</div>
|
||||||
|
{%- if user.description %}<div>[D] {{ user.description }}</div>{% endif -%}
|
||||||
|
{%- if user.repos | length > 0 %}
|
||||||
|
<div class="user-repos">
|
||||||
|
<div>Repositories:</div>
|
||||||
|
{% for repo in user.repos %}
|
||||||
|
<div>{{ macros::compact(name=repo[1].name, desc=repo[1].description) }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif -%}
|
||||||
|
{%- if user.issues | length > 0 %}
|
||||||
|
<div class="user-issues">
|
||||||
|
<div>Issues:</div>
|
||||||
|
{% for issue in user.issues %}
|
||||||
|
<div>{{ macros::compact(name=issue[1].title, desc=issue[1].body) }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif -%}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
10
templates/macros.html
Normal file
10
templates/macros.html
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
{% macro compact(name, desc) %}
|
||||||
|
{% if desc | length <= 150 %}
|
||||||
|
{{ name }} | {{ desc }}
|
||||||
|
{% else %}
|
||||||
|
<details>
|
||||||
|
<summary>{{ name }} | {{ desc | truncate(length=150) }}</summary>
|
||||||
|
{{ desc }}
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro compact %}
|
Loading…
Add table
Reference in a new issue