Compare commits
6 commits
a3de1942c8
...
d6fc5d373a
Author | SHA1 | Date | |
---|---|---|---|
d6fc5d373a | |||
41b27830b9 | |||
776cbe6d52 | |||
eccf4810a9 | |||
808b03b817 | |||
44807f408f |
18 changed files with 274 additions and 61 deletions
|
@ -16,3 +16,7 @@ BIND_PORT=1312
|
|||
# Database connection string
|
||||
# Full schema: postgres://user:password@host:port/database
|
||||
DATABASE_URL=postgres://nyanoblog@localhost/nyanoblog
|
||||
|
||||
# HMAC secret for issuing JSON Web Tokens, generate with:
|
||||
# dd if=/dev/random bs=1M count=1 | sha256sum
|
||||
JWT_SECRET=
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
CREATE TABLE notes (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
account_id BIGINT REFERENCES accounts(id),
|
||||
account_id BIGINT REFERENCES accounts (id),
|
||||
uri VARCHAR,
|
||||
content TEXT NOT NULL,
|
||||
source TEXT DEFAULT NULL,
|
||||
summary TEXT DEFAULT NULL,
|
||||
sensitive BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX index_notes_on_account_id ON notes (account_id);
|
||||
CREATE UNIQUE INDEX index_notes_on_uri ON notes (uri);
|
||||
CREATE INDEX index_notes_on_created_at ON notes (created_at);
|
||||
|
|
|
@ -3,7 +3,9 @@ CREATE TABLE users (
|
|||
account_id BIGINT REFERENCES accounts (id),
|
||||
email VARCHAR NOT NULL,
|
||||
password VARCHAR NOT NULL,
|
||||
activated BOOLEAN NOT NULL DEFAULT FALSE
|
||||
reason VARCHAR DEFAULT NULL,
|
||||
activated BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
locale VARCHAR NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX index_users_on_email ON users (email);
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
use serde;
|
||||
use sqlx::{Executor, PgPool};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::core::*;
|
||||
use crate::model::account::{Account, NewAccount};
|
||||
|
@ -30,13 +29,16 @@ impl PgAccountDataSource {
|
|||
T: Into<NewAccount> + Send,
|
||||
{
|
||||
let new = new.into();
|
||||
let account: Account =
|
||||
sqlx::query_as("INSERT INTO accounts (name, domain, display_name) VALUES ($1, $2, $3)")
|
||||
.bind(new.name)
|
||||
.bind(new.domain)
|
||||
.bind(new.display_name)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
let account: Account = sqlx::query_as(
|
||||
"INSERT INTO accounts (name, domain, display_name)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *",
|
||||
)
|
||||
.bind(new.name)
|
||||
.bind(new.domain)
|
||||
.bind(new.display_name)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(account)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,9 +31,9 @@ impl PgNoteDataSource {
|
|||
{
|
||||
let id: Id = id.into();
|
||||
let notes: Vec<Note> = sqlx::query_as(
|
||||
"SELECT * FROM notes \
|
||||
WHERE account_id = $1 AND created_at < $1 \
|
||||
ORDER BY created_at \
|
||||
"SELECT * FROM notes
|
||||
WHERE account_id = $1 AND created_at < $1
|
||||
ORDER BY created_at
|
||||
LIMIT 20",
|
||||
)
|
||||
.bind(id)
|
||||
|
@ -49,17 +49,20 @@ impl PgNoteDataSource {
|
|||
{
|
||||
let new = new.into();
|
||||
let note: Note = sqlx::query_as(
|
||||
"INSERT INTO notes ( \
|
||||
account_id, \
|
||||
uri, \
|
||||
content, \
|
||||
summary, \
|
||||
sensitive \
|
||||
) VALUES ($1, $2, $3, $4, $5)",
|
||||
"INSERT INTO notes (
|
||||
account_id,
|
||||
uri,
|
||||
content,
|
||||
source,
|
||||
summary,
|
||||
sensitive
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *",
|
||||
)
|
||||
.bind(new.account_id)
|
||||
.bind(new.uri)
|
||||
.bind(new.content)
|
||||
.bind(new.source)
|
||||
.bind(new.summary)
|
||||
.bind(new.sensitive)
|
||||
.fetch_one(&self.pool)
|
||||
|
|
|
@ -13,11 +13,7 @@ impl PgUserDataSource {
|
|||
PgUserDataSource { pool }
|
||||
}
|
||||
|
||||
pub async fn by_id<I>(&self, id: I) -> Result<User>
|
||||
where
|
||||
I: Into<Id> + Send,
|
||||
{
|
||||
let id: Id = id.into();
|
||||
pub async fn by_id(&self, id: Id) -> Result<User> {
|
||||
let user: User = sqlx::query_as("SELECT * FROM users WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_one(&self.pool)
|
||||
|
@ -25,30 +21,35 @@ impl PgUserDataSource {
|
|||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn by_account<I>(&self, account_id: I) -> Result<User>
|
||||
where
|
||||
I: Into<Id> + Send,
|
||||
{
|
||||
let id: Id = account_id.into();
|
||||
pub async fn by_account(&self, account_id: Id) -> Result<User> {
|
||||
let user: User = sqlx::query_as("SELECT * FROM users WHERE account_id = $1")
|
||||
.bind(id)
|
||||
.bind(account_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn create<T>(&self, new: T) -> Result<User>
|
||||
where
|
||||
T: Into<NewUser> + Send,
|
||||
{
|
||||
let new = new.into();
|
||||
let user: User =
|
||||
sqlx::query_as("INSERT INTO users (account_id, email, password) VALUES ($1, $2, $3)")
|
||||
.bind(new.account_id)
|
||||
.bind(new.email)
|
||||
.bind(new.password)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
pub async fn by_email(&self, email: &str) -> Result<User> {
|
||||
let user: User = sqlx::query_as("SELECT * FROM users WHERE email = $1")
|
||||
.bind(email)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn create(&self, new: NewUser) -> Result<User> {
|
||||
let user: User = sqlx::query_as(
|
||||
"INSERT INTO users (account_id, email, password, reason, locale) \
|
||||
VALUES ($1, $2, $3, $4, $5) \
|
||||
RETURNING *",
|
||||
)
|
||||
.bind(new.account_id)
|
||||
.bind(new.email)
|
||||
.bind(new.password)
|
||||
.bind(new.reason)
|
||||
.bind(new.locale)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
|
|
36
src/main.rs
36
src/main.rs
|
@ -3,14 +3,50 @@ use sqlx::postgres::PgPoolOptions;
|
|||
use sqlx::{migrate, PgPool};
|
||||
|
||||
mod conf;
|
||||
|
||||
/// Core data types used throughout the entire app.
|
||||
mod core;
|
||||
|
||||
/// Data sources for repositories.
|
||||
///
|
||||
/// Data sources are the interfaces between repositories and an underlying
|
||||
/// storage layer, such as Postgres or Redis. Each data source is only
|
||||
/// responsible for a single storage engine, and a repository may have multiple
|
||||
/// data sources of different preferences.
|
||||
mod data;
|
||||
|
||||
/// Asynchronous background workers.
|
||||
mod job;
|
||||
|
||||
/// Database models and validation.
|
||||
mod model;
|
||||
|
||||
/// The central interface between data storage and the rest of the app.
|
||||
///
|
||||
/// This module contains all _Repositories_, which are the central interface
|
||||
/// between the data storage/management code and the rest of the server.
|
||||
/// They act as a full layer of abstraction to keep the rest of the app
|
||||
/// completely agnostic to the internals of how storage, including caching,
|
||||
/// is implemented.
|
||||
///
|
||||
/// Any data that the rest of the app needs access to or wishes to store must
|
||||
/// travel through an interface provided by a repository. This makes them
|
||||
/// perfect gatekeepers for enforcing validation, which is why APIs that store
|
||||
/// data will typically be implemented as generics that require an
|
||||
/// `Into<Sane<T>>` (where `T` is the model structure for that database table).
|
||||
///
|
||||
/// Internally, repositories act on several _Data Sources_.
|
||||
/// See the [`data`] module for more information.
|
||||
mod repo;
|
||||
|
||||
/// HTTP request handlers.
|
||||
mod route;
|
||||
|
||||
mod state;
|
||||
|
||||
/// Miscellaneous little helpers.
|
||||
mod util;
|
||||
|
||||
use crate::core::*;
|
||||
use conf::Config;
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
|
|||
use sqlx::FromRow;
|
||||
|
||||
use crate::core::Id;
|
||||
use crate::util::validate::{ResultBuilder, Validate};
|
||||
|
||||
#[derive(Deserialize, Serialize, FromRow)]
|
||||
pub struct Account {
|
||||
|
@ -25,3 +26,18 @@ impl From<Account> for Id {
|
|||
account.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Validate for NewAccount {
|
||||
fn validate(&self, builder: ResultBuilder) -> ResultBuilder {
|
||||
builder
|
||||
.field(&self.name, "name", |f| {
|
||||
f.check("Must not be empty", |v| v.len() > 0)
|
||||
.check("Must be at most 16 characters long", |v| v.len() <= 16)
|
||||
})
|
||||
.field(&self.display_name, "display_name", |f| {
|
||||
f.check("Must be at most 64 characters long", |v| {
|
||||
v.as_ref().map(|v| v.len()).unwrap_or(0) <= 64
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ pub struct Note {
|
|||
pub account_id: Id,
|
||||
pub uri: Option<String>,
|
||||
pub content: String,
|
||||
pub source: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
pub sensitive: bool,
|
||||
pub created_at: NaiveDateTime,
|
||||
|
@ -20,6 +21,7 @@ pub struct NewNote {
|
|||
pub account_id: Id,
|
||||
pub uri: String,
|
||||
pub content: String,
|
||||
pub source: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
pub sensitive: bool,
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
|
|||
use sqlx::FromRow;
|
||||
|
||||
use crate::core::*;
|
||||
use crate::util::validate::{ResultBuilder, Validate};
|
||||
|
||||
#[derive(Deserialize, Serialize, FromRow)]
|
||||
pub struct User {
|
||||
|
@ -9,12 +10,17 @@ pub struct User {
|
|||
pub account_id: Id,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub reason: Option<String>,
|
||||
pub locale: String,
|
||||
pub activated: bool,
|
||||
}
|
||||
|
||||
pub struct NewUser {
|
||||
pub account_id: Id,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub reason: Option<String>,
|
||||
pub locale: String,
|
||||
}
|
||||
|
||||
impl From<User> for Id {
|
||||
|
@ -22,3 +28,11 @@ impl From<User> for Id {
|
|||
user.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Validate for NewUser {
|
||||
fn validate(&self, builder: ResultBuilder) -> ResultBuilder {
|
||||
builder.field(&self.email, "email", |f| {
|
||||
f.check("Email must not be empty", |v| v.len() > 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,10 +22,13 @@ impl AccountRepo {
|
|||
self.db.by_id(id).await
|
||||
}
|
||||
|
||||
pub async fn create<T>(&self, new: T) -> Result<Account>
|
||||
pub async fn create<T, E>(&self, new: T) -> Result<Account>
|
||||
where
|
||||
T: Into<NewAccount> + Send,
|
||||
T: TryInto<Sane<NewAccount>, Error = E> + Send,
|
||||
E: Into<Error>,
|
||||
{
|
||||
self.db.create(new).await
|
||||
self.db
|
||||
.create(new.try_into().map_err(|e| e.into())?.inner())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,20 +19,30 @@ impl UserRepo {
|
|||
where
|
||||
I: Into<Id> + Send,
|
||||
{
|
||||
self.db.by_id(id).await
|
||||
self.db.by_id(id.into()).await
|
||||
}
|
||||
|
||||
pub async fn by_account<I>(&self, account_id: I) -> Result<User>
|
||||
where
|
||||
I: Into<Id> + Send,
|
||||
{
|
||||
self.db.by_account(account_id).await
|
||||
self.db.by_account(account_id.into()).await
|
||||
}
|
||||
|
||||
pub async fn create<T>(&self, new: T) -> Result<User>
|
||||
pub async fn by_email<T>(&self, email: T) -> Result<User>
|
||||
where
|
||||
T: Into<NewUser> + Send,
|
||||
T: AsRef<str>,
|
||||
{
|
||||
self.db.create(new).await
|
||||
self.db.by_email(email.as_ref()).await
|
||||
}
|
||||
|
||||
pub async fn create<T, E>(&self, new: T) -> Result<User>
|
||||
where
|
||||
T: TryInto<Sane<NewUser>, Error = E> + Send,
|
||||
E: Into<Error>,
|
||||
{
|
||||
self.db
|
||||
.create(new.try_into().map_err(|e| e.into())?.inner())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
use actix_web::web;
|
||||
|
||||
pub mod nyano;
|
||||
pub mod v1;
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(web::scope("/v1").configure(v1::configure));
|
||||
cfg.service(web::scope("/v1").configure(v1::configure))
|
||||
.service(web::scope("/nyano").configure(nyano::configure));
|
||||
}
|
||||
|
|
7
src/route/api/nyano/mod.rs
Normal file
7
src/route/api/nyano/mod.rs
Normal file
|
@ -0,0 +1,7 @@
|
|||
use actix_web::web;
|
||||
|
||||
pub mod v1;
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(web::scope("/v1").configure(v1::configure));
|
||||
}
|
43
src/route/api/nyano/v1/auth.rs
Normal file
43
src/route/api/nyano/v1/auth.rs
Normal file
|
@ -0,0 +1,43 @@
|
|||
use actix_web::{post, web, HttpResponse};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::core::*;
|
||||
use crate::model::Account;
|
||||
use crate::state::AppState;
|
||||
use crate::util::{password, token};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AuthRequest {
|
||||
email: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AuthResponse {
|
||||
token: String,
|
||||
account: Account,
|
||||
}
|
||||
|
||||
#[post("")]
|
||||
async fn auth(body: web::Json<AuthRequest>, state: AppState) -> Result<HttpResponse> {
|
||||
let body = body.into_inner();
|
||||
// make sure unknown email address returns the same error as invalid password
|
||||
let user = state
|
||||
.repo
|
||||
.users
|
||||
.by_email(body.email)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
Error::NotFound => Error::BadCredentials,
|
||||
e => e,
|
||||
})?;
|
||||
password::verify(&body.password, &user.password).map_err(|_| Error::BadCredentials)?;
|
||||
let account = state.repo.accounts.by_id(user.account_id).await?;
|
||||
let token = token::issue(&state, &account)?;
|
||||
info!(target: "auth", "Successful login for user {}", &account.name);
|
||||
Ok(HttpResponse::Ok().json(AuthResponse { account, token }))
|
||||
}
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(auth);
|
||||
}
|
7
src/route/api/nyano/v1/mod.rs
Normal file
7
src/route/api/nyano/v1/mod.rs
Normal file
|
@ -0,0 +1,7 @@
|
|||
use actix_web::web;
|
||||
|
||||
pub mod auth;
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(web::scope("/auth").configure(auth::configure));
|
||||
}
|
|
@ -1,8 +1,63 @@
|
|||
use actix_web::{get, web, HttpRequest, HttpResponse, Responder};
|
||||
use actix_web::{get, post, web, HttpResponse};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::core::*;
|
||||
use crate::model::account::Account;
|
||||
use crate::model::{NewAccount, NewUser};
|
||||
use crate::state::AppState;
|
||||
use crate::util::password;
|
||||
use crate::util::validate::{ResultBuilder, Validate};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SignupData {
|
||||
username: String,
|
||||
email: String,
|
||||
password: String,
|
||||
agreement: bool,
|
||||
locale: String,
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
||||
#[post("")]
|
||||
async fn signup(data: web::Json<SignupData>, state: AppState) -> Result<HttpResponse> {
|
||||
let data: Sane<SignupData> = Insane::from(data.into_inner()).try_into()?;
|
||||
let data = data.inner();
|
||||
let account = state
|
||||
.repo
|
||||
.accounts
|
||||
.create(Insane::from(NewAccount {
|
||||
name: data.username,
|
||||
domain: "localhost".into(),
|
||||
display_name: None,
|
||||
}))
|
||||
.await?;
|
||||
state
|
||||
.repo
|
||||
.users
|
||||
.create(Insane::from(NewUser {
|
||||
account_id: account.id,
|
||||
email: data.email,
|
||||
password: password::hash(&data.password),
|
||||
locale: data.locale,
|
||||
reason: data.reason,
|
||||
}))
|
||||
.await?;
|
||||
Ok(HttpResponse::Ok().finish())
|
||||
}
|
||||
|
||||
impl Validate for SignupData {
|
||||
fn validate(&self, builder: ResultBuilder) -> ResultBuilder {
|
||||
builder
|
||||
.check(
|
||||
&self.agreement,
|
||||
"agreement",
|
||||
"You must accept the Terms of Service",
|
||||
|v| *v,
|
||||
)
|
||||
.field(&self.password, "password", |f| {
|
||||
f.check("Must be at least 8 characters long", |v| v.len() >= 8)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/{id}")]
|
||||
async fn get_by_id(path: web::Path<Id>, state: AppState) -> Result<HttpResponse> {
|
||||
|
@ -20,5 +75,5 @@ async fn get_notes(path: web::Path<Id>, state: AppState) -> Result<HttpResponse>
|
|||
}
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(get_by_id).service(get_notes);
|
||||
cfg.service(get_by_id).service(get_notes).service(signup);
|
||||
}
|
||||
|
|
|
@ -2,7 +2,9 @@ use argon2::password_hash::rand_core::OsRng;
|
|||
use argon2::password_hash::SaltString;
|
||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||
|
||||
pub fn hash(clear: String) -> String {
|
||||
use crate::core::*;
|
||||
|
||||
pub fn hash(clear: &String) -> String {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
argon2
|
||||
|
@ -11,9 +13,10 @@ pub fn hash(clear: String) -> String {
|
|||
.to_string()
|
||||
}
|
||||
|
||||
pub fn verify(clear: String, hash: String) -> bool {
|
||||
pub fn verify(clear: &String, hash: &String) -> Result<()> {
|
||||
let parsed_hash = PasswordHash::new(&hash).unwrap();
|
||||
Argon2::default()
|
||||
.verify_password(clear.as_bytes(), &parsed_hash)
|
||||
.is_ok()
|
||||
match Argon2::default().verify_password(clear.as_bytes(), &parsed_hash) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(_) => Err(Error::BadCredentials),
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue