mirror of
https://github.com/oxen-io/session-open-group-server.git
synced 2023-12-13 20:30:35 +01:00
1603 lines
57 KiB
Rust
1603 lines
57 KiB
Rust
use std::collections::HashMap;
|
|
use std::convert::TryFrom;
|
|
use std::io::{Read, Write};
|
|
use std::path::Path;
|
|
use std::time::{Duration, SystemTime};
|
|
|
|
use base64;
|
|
use ed25519_dalek::Signer;
|
|
use log::{debug, error, info, warn};
|
|
use parking_lot::RwLock;
|
|
use r2d2::PooledConnection;
|
|
use r2d2_sqlite::SqliteConnectionManager;
|
|
use regex::Regex;
|
|
use rusqlite::{params, params_from_iter};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::json;
|
|
use warp::{http::StatusCode, reply::Reply, reply::Response, Rejection};
|
|
|
|
use super::crypto;
|
|
use super::errors::Error;
|
|
use super::models;
|
|
use super::models::{OldMessage, Room, User};
|
|
use super::rpc;
|
|
use super::storage::{self, db_error};
|
|
|
|
// TODO FIXME: the user/room arguments are rather random in here. Should rearrange them all so
|
|
// that room-dependent functions have args (room, user, ...)
|
|
|
|
// Type for requiring permissions for various endpoints. Note that `moderator` is satisfied by
|
|
// someone being an admin in the database, and read/write/upload are always considered satisfied
|
|
// for moderators/admins.
|
|
#[derive(Default)]
|
|
pub struct AuthorizationRequired {
|
|
pub admin: bool, // Required admin permission (server or room)
|
|
pub moderator: bool, // Requires moderator or admin permission (server or room)
|
|
pub read: bool, // Requires read permission
|
|
pub write: bool, // Requires write permission
|
|
pub upload: bool, // Requires upload permission
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct GenericStringResponse {
|
|
pub status_code: u16,
|
|
pub result: String,
|
|
}
|
|
|
|
// FIXME: this is used to query the github API periodically to find new releases. Ew.
|
|
pub const SESSION_VERSION_UPDATE_INTERVAL: i64 = 30 * 60;
|
|
|
|
// Default duration for getting an active user count in a room. Should be <=
|
|
// storage::ROOM_ACTIVE_PRUNE_THRESHOLD.
|
|
pub const ROOM_DEFAULT_ACTIVE_THRESHOLD: Duration = Duration::from_secs(7 * 86400);
|
|
|
|
// Rate limit posting if the user has posted N or more messages in the last M seconds. This is a
|
|
// very crude way of rate limiting, but it should be sufficient for now.
|
|
//
|
|
// TODO: allow individual rooms to have more restricted rate limiting.
|
|
pub const RATE_LIMIT_POSTS: i64 = 5;
|
|
pub const RATE_LIMIT_INTERVAL: i64 = 16;
|
|
|
|
lazy_static::lazy_static! {
|
|
|
|
pub static ref SESSION_VERSIONS: RwLock<HashMap<String, (i64, String)>> = RwLock::new(HashMap::new());
|
|
|
|
// We strip out anything that matches here in an uploaded filename and replace with an _.
|
|
pub static ref UPLOAD_FILENAME_BAD: Regex = Regex::new(r"[^\w+\-.'()@\[\]]+").unwrap();
|
|
}
|
|
|
|
// We truncate filenames if the sanitized name (not including the initial 'ID_') is longer than
|
|
// this.
|
|
pub const UPLOAD_FILENAME_MAX: usize = 60;
|
|
// When a filename exceeds _MAX, we keep this much from the beginning, append ..., and then append
|
|
// enough from the end (i.e. max - this - 3) to hit the _MAX value.
|
|
pub const UPLOAD_FILENAME_KEEP_PREFIX: usize = 40;
|
|
pub const UPLOAD_FILENAME_KEEP_SUFFIX: usize = 17;
|
|
|
|
// How long until an upload expires.
|
|
//
|
|
// TODO FIXME -- this could easily be a per-room property. Note that room image uploads do not
|
|
// expire (until they are replaced).
|
|
pub const UPLOAD_DEFAULT_EXPIRY: Duration = Duration::from_secs(15 * 86400);
|
|
|
|
// Backwards compatibility token sizes. We return a "token" consisting of [SESSIONID][SIGNATURE]
|
|
// where SESSIONID is the provided session id (in bytes) and SIGNATURE is the session ID signed by
|
|
// the server's crypto::TOKEN_SIGNING_KEY. (This all gets encrypted and sent back to the server,
|
|
// where we parse and verify the signature).
|
|
pub const TOKEN_ID_SIZE: usize = 33;
|
|
pub const TOKEN_SIG_SIZE: usize = 64;
|
|
pub const TOKEN_SIZE: usize = TOKEN_ID_SIZE + TOKEN_SIG_SIZE;
|
|
|
|
// Rooms
|
|
//
|
|
#[derive(Deserialize)]
|
|
pub struct CreateRoom {
|
|
pub token: String,
|
|
pub name: String,
|
|
}
|
|
|
|
// Not publicly exposed.
|
|
|
|
pub async fn create_room(room: CreateRoom) -> Result<Response, Rejection> {
|
|
storage::RoomId::validate(&room.token)?;
|
|
|
|
// Get a connection
|
|
let conn = storage::get_conn()?;
|
|
if let Err(e) = create_room_with_conn(&conn, &room) {
|
|
error!("Couldn't create room: {}.", e);
|
|
return Err(warp::reject::custom(Error::DatabaseFailedInternally));
|
|
}
|
|
|
|
// Return
|
|
info!("Added room with ID: {}", room.token);
|
|
let json = models::StatusCode { status_code: StatusCode::OK.as_u16() };
|
|
Ok(warp::reply::json(&json).into_response())
|
|
}
|
|
|
|
pub fn create_room_with_conn(
|
|
conn: &PooledConnection<SqliteConnectionManager>,
|
|
room: &CreateRoom,
|
|
) -> Result<usize, rusqlite::Error> {
|
|
// Insert the room
|
|
let stmt = "INSERT INTO rooms (token, name) VALUES (?, ?) \
|
|
ON CONFLICT DO UPDATE SET token = excluded.token, name = excluded.name";
|
|
return conn.execute(&stmt, params![room.token, room.name]);
|
|
}
|
|
|
|
// Not publicly exposed.
|
|
pub async fn delete_room(token: String) -> Result<Response, Rejection> {
|
|
// Get a connection
|
|
let conn = storage::get_conn()?;
|
|
// Insert the room
|
|
let stmt = "DELETE FROM rooms WHERE token = ?";
|
|
let count = match conn.execute(&stmt, params![&token]) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
error!("Couldn't delete room: {}.", e);
|
|
return Err(warp::reject::custom(Error::DatabaseFailedInternally));
|
|
}
|
|
};
|
|
let status_code = if count > 0 {
|
|
info!("Deleted room with ID: {}", &token);
|
|
StatusCode::OK
|
|
} else {
|
|
warn!("Room with ID {} not found", &token);
|
|
StatusCode::NOT_FOUND
|
|
}
|
|
.as_u16();
|
|
let json = models::StatusCode { status_code };
|
|
Ok(warp::reply::json(&json).into_response())
|
|
}
|
|
|
|
// TODO FIXME -- this endpoint is entirely unauthenticated, which doesn't seem desirable because it
|
|
// means we can't enforce bans at the global or room level. This is undesirable: we should require
|
|
// an authenticated request for *every* endpoint. (Also: get_room, get_room_image).
|
|
//
|
|
// The authentication level here could simply be that it requires nothing (which will become simply
|
|
// !banned).
|
|
|
|
// Deprecated: returns just "id" (actually token) and name, as older Session clients expect.
|
|
pub fn get_room_v01x(room: &Room) -> Result<Response, Rejection> {
|
|
let response = json!({
|
|
"status_code": StatusCode::OK.as_u16(),
|
|
"room": {
|
|
"id": room.token,
|
|
"name": room.name
|
|
}
|
|
});
|
|
Ok(warp::reply::json(&response).into_response())
|
|
}
|
|
|
|
// TODO FIXME -- this endpoint is entirely unauthenticated, which doesn't seem desirable because it
|
|
// means we can't enforce bans at the global or room level. This is undesirable: we should require
|
|
// an authenticated request for *every* endpoint. (Also: get_room, get_room_image).
|
|
|
|
fn get_all_rooms_impl() -> Result<Vec<Room>, Rejection> {
|
|
match storage::get_conn()?
|
|
.prepare_cached("SELECT * from rooms ORDER BY token")
|
|
.map_err(db_error)?
|
|
.query_map(params![], Room::from_row)
|
|
{
|
|
Ok(rows) => rows,
|
|
Err(e) => {
|
|
error!("Couldn't get rooms: {}.", e);
|
|
return Err(Error::DatabaseFailedInternally.into());
|
|
}
|
|
}
|
|
.collect::<Result<Vec<Room>, _>>()
|
|
.map_err(|e| db_error(e).into())
|
|
}
|
|
|
|
// Deprecated: returns just "id" (actually the token) and name for each room, as older Session
|
|
// clients expect.
|
|
pub fn get_all_rooms_v01x() -> Result<Response, Rejection> {
|
|
#[derive(Debug, Serialize)]
|
|
struct OldRoom {
|
|
id: String,
|
|
name: String,
|
|
}
|
|
|
|
let rooms = get_all_rooms_impl()?
|
|
.into_iter()
|
|
.map(|r| OldRoom { id: r.token, name: r.name })
|
|
.collect::<Vec<OldRoom>>();
|
|
|
|
let response = json!({ "status_code": StatusCode::OK.as_u16(), "rooms": rooms });
|
|
Ok(warp::reply::json(&response).into_response())
|
|
}
|
|
|
|
// Files
|
|
|
|
/// RAII class holding an in-progress upload transaction and path details. If this is dropped
|
|
/// without `commit()` being called we remove the file from disk and abort the transaction
|
|
/// inserting the upload into the database.
|
|
pub struct FileUpload<'a> {
|
|
pub id: i64, // The value of `id` in the `files` table for this new file
|
|
pub room: &'a Room, // The room the file is uploaded to
|
|
pub path: String, // The relative path containing the in-progress file upload
|
|
tx: Option<rusqlite::Transaction<'a>>,
|
|
committed: bool,
|
|
}
|
|
impl FileUpload<'_> {
|
|
pub fn new<'a>(tx: rusqlite::Transaction<'a>, room: &'a Room) -> FileUpload<'a> {
|
|
FileUpload { id: -1, room, path: "".to_string(), tx: Some(tx), committed: false }
|
|
}
|
|
// Commits the transaction; if this succeeds the file won't be deleted on destruction
|
|
pub fn commit(&mut self) -> Result<(), rusqlite::Error> {
|
|
if let Some(tx) = self.tx.take() {
|
|
tx.commit()?;
|
|
self.committed = true;
|
|
}
|
|
Ok(())
|
|
}
|
|
pub fn prepare_cached(&self, query: &str) -> rusqlite::Result<rusqlite::CachedStatement<'_>> {
|
|
self.tx.as_ref().unwrap().prepare_cached(query)
|
|
}
|
|
}
|
|
impl Drop for FileUpload<'_> {
|
|
fn drop(&mut self) {
|
|
if !self.committed && !self.path.is_empty() {
|
|
let _ = std::fs::remove_file(&self.path);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Does the actual work involved in storing a file, inserting into the database, etc.
|
|
///
|
|
/// Returns a FileUpload on success. The caller may optionally use this to perform additional
|
|
/// actions, but *must* call `.commit()` on success -- if dropped the FileUpload will clean up the
|
|
/// temporary file and drop the transaction inserting the records.
|
|
pub fn store_file_impl<'a>(
|
|
conn: &'a mut storage::DatabaseConnection,
|
|
room: &'a Room,
|
|
user: &User,
|
|
auth: AuthorizationRequired,
|
|
data_b64: &str,
|
|
filename: Option<&str>,
|
|
expires: bool,
|
|
) -> Result<FileUpload<'a>, Rejection> {
|
|
// Determine the file size from the base64 data without decoding it (we'll do that later
|
|
// directly to the destination file).
|
|
let mut bytes: usize = data_b64.len() / 4 * 3;
|
|
match data_b64.len() % 4 {
|
|
0 => {
|
|
// Even multiple of 4, but we might have padding:
|
|
if data_b64.ends_with('=') {
|
|
// Every 3 bytes of data becomes 4 bytes in base64. If the end is a 4 byte value
|
|
// with padding then two padding chars means it was created from a single byte (and
|
|
// we're using 6+2 bits in the first two significant chars), and one padding char
|
|
// means it was created from two bytes (using 6+6+4 bytes of encoded significance).
|
|
bytes -= if data_b64.ends_with("==") { 2 } else { 1 };
|
|
}
|
|
}
|
|
// Input of size 3n+{1,2} will produce 4n+{2,3} (unpadded) bytes:
|
|
2 => {
|
|
bytes += 1;
|
|
}
|
|
3 => {
|
|
bytes += 2;
|
|
}
|
|
// This is just invalid base64, so bail now:
|
|
_ => {
|
|
error!("Invalid file data: data is not properly base64 encoded");
|
|
return Err(Error::ValidationFailed.into());
|
|
}
|
|
};
|
|
|
|
let files_dir = format!("uploads/{}", room.token);
|
|
if let Err(e) = std::fs::create_dir_all(&files_dir) {
|
|
error!("Unable to mkdir {} for room file storage: {}", files_dir, e);
|
|
return Err(Error::DatabaseFailedInternally.into());
|
|
}
|
|
|
|
let mut upload = FileUpload::new(storage::get_transaction(conn)?, &room);
|
|
|
|
require_authorization(&upload.tx.as_ref().unwrap(), &user, &room, auth)?;
|
|
|
|
let db_filename: Option<String> =
|
|
filename.map(|f| UPLOAD_FILENAME_BAD.replace_all(f, "_").into());
|
|
let expiry: Option<f64> = if expires {
|
|
Some(
|
|
(SystemTime::now() + UPLOAD_DEFAULT_EXPIRY)
|
|
.duration_since(SystemTime::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs_f64(),
|
|
)
|
|
} else {
|
|
None
|
|
};
|
|
upload.id = match upload
|
|
.tx
|
|
.as_ref()
|
|
.unwrap()
|
|
.prepare_cached(
|
|
"INSERT INTO files (room, uploader, size, expiry, filename, path) \
|
|
VALUES (?, ?, ?, ?, ?, 'tmp') \
|
|
RETURNING id",
|
|
)
|
|
.map_err(db_error)?
|
|
.query_row(params![room.id, user.id, bytes, db_filename, expiry], |row| row.get(0))
|
|
{
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
error!("Couldn't insert file row: {}.", e);
|
|
return Err(Error::DatabaseFailedInternally.into());
|
|
}
|
|
};
|
|
|
|
let mut fs_filename: String = db_filename.unwrap_or("(unnamed)".to_string());
|
|
|
|
assert!(UPLOAD_FILENAME_KEEP_PREFIX + 3 + UPLOAD_FILENAME_KEEP_SUFFIX <= UPLOAD_FILENAME_MAX);
|
|
if fs_filename.len() > UPLOAD_FILENAME_MAX {
|
|
fs_filename.replace_range(
|
|
UPLOAD_FILENAME_KEEP_PREFIX..fs_filename.len() - UPLOAD_FILENAME_KEEP_SUFFIX,
|
|
"...",
|
|
);
|
|
}
|
|
fs_filename = format!("{}/{}_{}", files_dir, upload.id, fs_filename);
|
|
if let Err(e) = upload
|
|
.prepare_cached("UPDATE files SET path = ? WHERE id = ?")
|
|
.map_err(db_error)?
|
|
.execute(params![fs_filename, upload.id])
|
|
{
|
|
error!("Unable to update stored path to '{}': {}", fs_filename, e);
|
|
return Err(Error::DatabaseFailedInternally.into());
|
|
}
|
|
|
|
// Write to file
|
|
let path = Path::new(&fs_filename);
|
|
// TODO: this could possibly be done async, but that seems dangerous while we hold an open
|
|
// transaction here that could cause other threads to block (and thus deadlock).
|
|
let mut file = match std::fs::File::create(path) {
|
|
Ok(file) => file,
|
|
Err(e) => {
|
|
error!("Couldn't open file '{}': {}.", fs_filename, e);
|
|
return Err(Error::DatabaseFailedInternally.into());
|
|
}
|
|
};
|
|
upload.path = fs_filename;
|
|
|
|
// Decode 65535 of output data at a time (we can't do 65536 because we need to read off the
|
|
// base64 in groups of 4 chars = groups of 3 bytes, and no power of 2 is divisible by 3).
|
|
let mut buf = Vec::<u8>::new();
|
|
let mut pos: usize = 0;
|
|
while pos < data_b64.len() {
|
|
let end = (pos + 87380).min(data_b64.len());
|
|
if let Err(e) =
|
|
base64::decode_config_buf(&data_b64.as_bytes()[pos..end], base64::STANDARD, &mut buf)
|
|
{
|
|
warn!("Invalid upload data: base64 decoding failed: {}", e);
|
|
return Err(Error::ValidationFailed.into());
|
|
}
|
|
if let Err(e) = file.write_all(&buf) {
|
|
error!("Writing to file {} failed: {}", upload.path, e);
|
|
return Err(Error::DatabaseFailedInternally.into());
|
|
}
|
|
pos = end;
|
|
buf.clear();
|
|
}
|
|
|
|
Ok(upload)
|
|
}
|
|
|
|
pub fn store_file(
|
|
room: &Room,
|
|
user: &User,
|
|
data_b64: &str,
|
|
filename: Option<&str>,
|
|
) -> Result<Response, Rejection> {
|
|
let mut conn = storage::get_conn()?;
|
|
if !matches!(rpc::MODE, rpc::Mode::OpenGroupServer) {
|
|
panic!("FIXME file mode FIXME FIXME TODO!");
|
|
// FIXME TODO
|
|
}
|
|
|
|
let auth = AuthorizationRequired { upload: true, write: true, ..Default::default() };
|
|
|
|
let mut upload = match store_file_impl(&mut conn, &room, &user, auth, data_b64, filename, true)
|
|
{
|
|
Ok(id) => id,
|
|
Err(e) => return Err(e),
|
|
};
|
|
|
|
if let Err(e) = upload.commit() {
|
|
error!("File upload failed: {}", e);
|
|
return Err(Error::DatabaseFailedInternally.into());
|
|
}
|
|
let response = json!({ "status_code": StatusCode::OK.as_u16(), "result": upload.id });
|
|
Ok(warp::reply::json(&response).into_response())
|
|
}
|
|
|
|
// Takes a .query_row response returning the `path` field from a files row and builds it into a
|
|
// file response.
|
|
fn file_response(path_row: rusqlite::Result<String>) -> Result<Response, Rejection> {
|
|
let raw_path = match path_row {
|
|
Ok(path) => path,
|
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
|
let response = json!({"status_code": StatusCode::NOT_FOUND.as_u16()});
|
|
return Ok(warp::reply::json(&response).into_response());
|
|
}
|
|
Err(e) => {
|
|
error!("Failed to query path from files table: {}", e);
|
|
return Err(Error::DatabaseFailedInternally.into());
|
|
}
|
|
};
|
|
|
|
// Try to read the file
|
|
let path = Path::new(&raw_path);
|
|
let mut file = match std::fs::File::open(path) {
|
|
Ok(file) => file,
|
|
Err(e) => {
|
|
error!("Couldn't read file: {}.", e);
|
|
return Err(Error::ValidationFailed.into());
|
|
}
|
|
};
|
|
|
|
let mut bytes = vec![];
|
|
if let Err(e) = file.read_to_end(&mut bytes) {
|
|
error!("Couldn't read file: {}.", e);
|
|
return Err(Error::DatabaseFailedInternally.into());
|
|
}
|
|
// Base64 encode the result
|
|
let base64_encoded_bytes = base64::encode(bytes);
|
|
// Return
|
|
let json = GenericStringResponse {
|
|
status_code: StatusCode::OK.as_u16(),
|
|
result: base64_encoded_bytes,
|
|
};
|
|
Ok(warp::reply::json(&json).into_response())
|
|
}
|
|
|
|
pub fn get_file(room: Room, id: i64, user: User) -> Result<Response, Rejection> {
|
|
let conn = storage::get_conn()?;
|
|
return get_file_conn(&conn, &room, id, user);
|
|
}
|
|
|
|
pub fn get_file_conn(
|
|
conn: &PooledConnection<SqliteConnectionManager>,
|
|
room: &Room,
|
|
id: i64,
|
|
user: User,
|
|
) -> Result<Response, Rejection> {
|
|
require_authorization(
|
|
&conn,
|
|
&user,
|
|
&room,
|
|
AuthorizationRequired { read: true, ..Default::default() },
|
|
)?;
|
|
|
|
let mut row = conn
|
|
.prepare_cached("SELECT path FROM files WHERE room = ? AND id = ?")
|
|
.map_err(db_error)?
|
|
.query_row(params![room.id, id], |row| row.get(0));
|
|
if row == Err(rusqlite::Error::QueryReturnedNoRows) && *storage::HAVE_FILE_ID_HACKS {
|
|
// Migration handling: we may have some file ID mappings from old IDs to new IDs in the
|
|
// file_id_hacks table, so try again using it
|
|
row = conn.prepare_cached("SELECT path FROM files WHERE id = \
|
|
(SELECT file FROM file_id_hacks WHERE room = ? AND old_file_id = ?)")
|
|
.map_err(db_error)?
|
|
.query_row(params![room.id, id], |row| row.get(0));
|
|
}
|
|
|
|
file_response(row)
|
|
}
|
|
|
|
// FIXME TODO This endpoint is currently not authenticated, which seems wrong. See comment above
|
|
// get_room()/get_all_rooms().
|
|
|
|
pub async fn get_room_image(room: Room) -> Result<Response, Rejection> {
|
|
let conn = storage::get_conn()?;
|
|
|
|
let row = conn
|
|
.prepare_cached(
|
|
"SELECT path FROM rooms JOIN files ON rooms.image = files.id WHERE rooms.id = ?",
|
|
)
|
|
.map_err(db_error)?
|
|
.query_row(params![room.id], |row| row.get(0));
|
|
file_response(row)
|
|
}
|
|
|
|
pub async fn set_room_image(
|
|
room: Room,
|
|
user: User,
|
|
data_b64: &str,
|
|
filename: Option<&str>,
|
|
) -> Result<Response, Rejection> {
|
|
let auth = AuthorizationRequired { moderator: true, ..Default::default() };
|
|
|
|
let mut conn = storage::get_conn()?;
|
|
let mut upload = store_file_impl(&mut conn, &room, &user, auth, data_b64, filename, false)?;
|
|
|
|
upload
|
|
.prepare_cached("UPDATE rooms SET image = ? WHERE id = ?")
|
|
.map_err(db_error)?
|
|
.execute(params![upload.id, room.id])
|
|
.map_err(db_error)?;
|
|
|
|
if let Err(e) = upload.commit() {
|
|
error!("File upload failed: {}", e);
|
|
return Err(Error::DatabaseFailedInternally.into());
|
|
}
|
|
|
|
let response = json!({ "status_code": StatusCode::OK.as_u16(), "result": upload.id });
|
|
Ok(warp::reply::json(&response).into_response())
|
|
}
|
|
|
|
// Authentication
|
|
|
|
/// Attempts to decode a received parameter of a fixed size; the parameter can be passed as either
|
|
/// hex or base64 (the latter with or without padding). Returns Error::InvalidRpcCall if neither
|
|
/// hex nor base64.
|
|
pub fn decode_hex_or_b64(value: &str, byte_size: usize) -> Result<Vec<u8>, Error> {
|
|
if value.len() == byte_size * 2 {
|
|
return hex::decode(value).map_err(|_| Error::InvalidRpcCall);
|
|
}
|
|
let b64_min = (byte_size * 4 + 2) / 3;
|
|
let b64_max = (b64_min + 3) & !3;
|
|
if value.len() >= b64_min && value.len() <= b64_max {
|
|
if let Ok(val) = base64::decode(value) {
|
|
if val.len() == byte_size {
|
|
return Ok(val);
|
|
}
|
|
}
|
|
}
|
|
return Err(Error::InvalidRpcCall);
|
|
}
|
|
|
|
pub fn insert_or_update_user(conn: &rusqlite::Connection, session_id: &str) -> Result<User, Error> {
|
|
Ok(conn
|
|
.prepare_cached(
|
|
"INSERT INTO users (session_id) VALUES (?) \
|
|
ON CONFLICT DO UPDATE SET last_active = ((julianday('now') - 2440587.5)*86400.0) \
|
|
RETURNING *",
|
|
)
|
|
.map_err(db_error)?
|
|
.query_row(params![&session_id], User::from_row)
|
|
.map_err(db_error)?)
|
|
}
|
|
|
|
// Validates a (backwards compat) token string.
|
|
pub fn get_user_from_token(
|
|
conn: &rusqlite::Connection,
|
|
auth_token_str: &str,
|
|
) -> Result<User, Error> {
|
|
let auth_token =
|
|
decode_hex_or_b64(auth_token_str, TOKEN_SIZE).map_err(|_| Error::NoAuthToken)?;
|
|
if auth_token[0] != 0x05 {
|
|
return Err(Error::NoAuthToken);
|
|
}
|
|
let (session_id_bytes, sig_bytes) =
|
|
(&auth_token[0..TOKEN_ID_SIZE], &auth_token[TOKEN_ID_SIZE..]);
|
|
let session_id = hex::encode(session_id_bytes);
|
|
let sig = ed25519_dalek::Signature::try_from(sig_bytes).map_err(|_| Error::NoAuthToken)?;
|
|
if let Err(sigerr) =
|
|
crypto::verify_signature(&crypto::TOKEN_SIGNING_KEYS.public, &sig, &[session_id_bytes])
|
|
{
|
|
warn!("Deprecated token signature verification failed for {}: {:?}", session_id, sigerr);
|
|
return Err(Error::NoAuthToken);
|
|
}
|
|
insert_or_update_user(conn, &session_id)
|
|
}
|
|
|
|
pub fn get_auth_token_challenge(public_key: &str) -> Result<models::Challenge, Rejection> {
|
|
// Doesn't return a response directly for testing purposes
|
|
|
|
require_session_id(public_key)?;
|
|
|
|
let session_bytes = hex::decode(public_key).unwrap();
|
|
let sig = crypto::TOKEN_SIGNING_KEYS.sign(&session_bytes);
|
|
|
|
let mut token = Vec::<u8>::new();
|
|
token.reserve_exact(TOKEN_SIZE);
|
|
token.extend_from_slice(&session_bytes);
|
|
token.extend_from_slice(sig.as_ref());
|
|
|
|
if token.len() != TOKEN_SIZE {
|
|
panic!("Internal error! unexpected generated token size");
|
|
}
|
|
|
|
// Chop off the leading 0x05, the rest is the x25519 pubkey
|
|
let pk_bytes = &session_bytes[1..];
|
|
|
|
// Generate an ephemeral key pair
|
|
let (ephemeral_private_key, ephemeral_public_key) = crypto::generate_x25519_key_pair();
|
|
// Generate a symmetric key from the requesting user's public key and the ephemeral private key
|
|
let symmetric_key = crypto::get_x25519_symmetric_key(&pk_bytes, &ephemeral_private_key)?;
|
|
|
|
// Encrypt the token with the symmetric key
|
|
let ciphertext = crypto::encrypt_aes_gcm(&token, &symmetric_key)?;
|
|
// Return
|
|
Ok(models::Challenge {
|
|
ciphertext: base64::encode(ciphertext),
|
|
ephemeral_public_key: base64::encode(ephemeral_public_key.to_bytes()),
|
|
})
|
|
}
|
|
|
|
// Message sending & receiving
|
|
|
|
// FIXME TODO - needs a flag to control whether it returns in new Message format instead of
|
|
// OldMessage
|
|
|
|
/// Inserts a message into the database.
|
|
pub fn insert_message(
|
|
room: Room,
|
|
user: User,
|
|
data: &[u8],
|
|
signature: &[u8],
|
|
) -> Result<Response, Rejection> {
|
|
let mut conn = storage::get_conn()?;
|
|
let tx = storage::get_transaction(&mut conn)?;
|
|
require_authorization(
|
|
&tx,
|
|
&user,
|
|
&room,
|
|
AuthorizationRequired { write: true, ..Default::default() },
|
|
)?;
|
|
|
|
// Check if the requesting user needs to be rate limited
|
|
|
|
let now_secs = unixtime_f64() - RATE_LIMIT_INTERVAL as f64;
|
|
|
|
let recent_posts: i64 = tx
|
|
.prepare_cached("SELECT COUNT(*) FROM messages WHERE room = ? AND user = ? AND posted >= ?")
|
|
.map_err(db_error)?
|
|
.query_row(params![room.id, user.id, now_secs], |row| row.get(0))
|
|
.map_err(db_error)?;
|
|
if recent_posts >= RATE_LIMIT_POSTS {
|
|
return Err(warp::reject::custom(Error::RateLimited));
|
|
}
|
|
|
|
// Don't store useless padding; we'll repad (since it's needed for signature verification) when
|
|
// we retrieve.
|
|
let size = data.len();
|
|
let trimmed = match data.iter().rposition(|&c| c != 0u8) {
|
|
Some(last) => &data[0..=last],
|
|
None => &data,
|
|
};
|
|
|
|
// Insert the message
|
|
let message = match tx
|
|
.prepare_cached(
|
|
"INSERT INTO messages (room, user, data, data_size, signature) \
|
|
VALUES (?, ?, ?, ?, ?) \
|
|
RETURNING *",
|
|
)
|
|
.map_err(db_error)?
|
|
.query_row(params![room.id, user.id, trimmed, size, signature], OldMessage::from_row)
|
|
{
|
|
Ok(m) => m,
|
|
Err(e) => {
|
|
error!("Couldn't insert message: {}.", e);
|
|
return Err(warp::reject::custom(Error::DatabaseFailedInternally));
|
|
}
|
|
};
|
|
|
|
// Commit
|
|
if let Err(e) = tx.commit() {
|
|
error!("Failed to commit message: {}", e);
|
|
return Err(warp::reject::custom(Error::DatabaseFailedInternally));
|
|
}
|
|
|
|
let response = json!({ "status_code": StatusCode::OK.as_u16(), "message": message });
|
|
Ok(warp::reply::json(&response).into_response())
|
|
}
|
|
|
|
// TODO FIXME: The paging mechanism here is really odd: you can either get the last 256 messages,
|
|
// *or* you can start getting messages from the beginning of time and walk forward. This is
|
|
// really, really inefficient, though, because if you join a very old room you could end up having
|
|
// to load the entire history from the beginning of time if you want to scroll back before the
|
|
// 256th-last message.
|
|
//
|
|
// (The same applies to get_deleted_messages, below).
|
|
|
|
fn get_messages_params(query_params: &HashMap<String, String>) -> (Option<i64>, u16) {
|
|
let from_server_id: Option<i64>;
|
|
if let Some(str) = query_params.get("from_server_id") {
|
|
from_server_id = str.parse::<i64>().ok();
|
|
} else {
|
|
from_server_id = None;
|
|
}
|
|
let limit: u16; // Never return more than 256 messages at once
|
|
if let Some(str) = query_params.get("limit") {
|
|
limit = std::cmp::min(str.parse().unwrap_or(256), 256);
|
|
} else {
|
|
limit = 256;
|
|
}
|
|
|
|
return (from_server_id, limit);
|
|
}
|
|
|
|
// FIXME: need something similar that returns new message format
|
|
|
|
/// Returns either the last `limit` messages or all messages since `from_server_id`, limited to
|
|
/// `limit`.
|
|
pub fn get_messages(
|
|
query_params: HashMap<String, String>,
|
|
user: User,
|
|
room: Room,
|
|
) -> Result<Vec<OldMessage>, Rejection> {
|
|
let conn = storage::get_conn()?;
|
|
|
|
require_authorization(
|
|
&conn,
|
|
&user,
|
|
&room,
|
|
AuthorizationRequired { read: true, ..Default::default() },
|
|
)?;
|
|
|
|
let (from_server_id, limit) = get_messages_params(&query_params);
|
|
|
|
let query = format!(
|
|
"SELECT messages.*, user.session_id FROM messages JOIN users ON messages.user = users.id \
|
|
WHERE data IS NOT NULL {} ORDER BY id {} LIMIT ?2",
|
|
if from_server_id.is_some() { "AND id > ?1" } else { "" },
|
|
if from_server_id.is_some() { "ASC" } else { "DESC" }
|
|
);
|
|
let result = match conn
|
|
.prepare_cached(&query)
|
|
.map_err(db_error)?
|
|
.query_map(params![from_server_id, limit], OldMessage::from_row)
|
|
{
|
|
Ok(rows) => rows,
|
|
Err(e) => {
|
|
error!("Couldn't get messages: {}.", e);
|
|
return Err(Error::DatabaseFailedInternally.into());
|
|
}
|
|
}
|
|
.collect::<Result<Vec<OldMessage>, _>>()
|
|
.map_err(|_| Error::DatabaseFailedInternally.into());
|
|
return result;
|
|
}
|
|
|
|
// Message deletion
|
|
|
|
/// Deletes the messages with the given `ids` from the database, if present.
|
|
pub fn delete_messages(ids: Vec<i64>, user: &User, room: &Room) -> Result<Response, Rejection> {
|
|
let mut conn = storage::get_conn()?;
|
|
let tx = storage::get_transaction(&mut conn)?;
|
|
|
|
for id in ids {
|
|
delete_message(&tx, id, &user, &room)?;
|
|
}
|
|
|
|
tx.commit().map_err(db_error)?;
|
|
|
|
let json = models::StatusCode { status_code: StatusCode::OK.as_u16() };
|
|
Ok(warp::reply::json(&json).into_response())
|
|
}
|
|
|
|
/// Deletes the message with the given `id` from the database, if it's present.
|
|
pub fn delete_message(
|
|
conn: &rusqlite::Connection,
|
|
id: i64,
|
|
user: &User,
|
|
room: &Room,
|
|
) -> Result<Response, Rejection> {
|
|
let mut auth_req = AuthorizationRequired { read: true, ..Default::default() };
|
|
|
|
// Check to see if the message to be deleted is owned by someone else: if it is, we require
|
|
// moderator access for the deletion.
|
|
let mut st = conn
|
|
.prepare_cached("SELECT COUNT(*) FROM messages WHERE room = ? AND id = ? AND user != ?")
|
|
.map_err(db_error)?;
|
|
|
|
match st.query_row(params![room.id, id, user.id], |row| row.get::<_, i64>(0)) {
|
|
Ok(count) => {
|
|
if count > 0 {
|
|
auth_req.moderator = true;
|
|
}
|
|
}
|
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
|
let response = json!({"status_code": StatusCode::NOT_FOUND.as_u16()});
|
|
return Ok(warp::reply::json(&response).into_response());
|
|
}
|
|
Err(_) => return Err(Error::DatabaseFailedInternally.into()),
|
|
};
|
|
|
|
require_authorization(conn, user, room, auth_req)?;
|
|
|
|
let mut del_st = conn
|
|
.prepare_cached(
|
|
"UPDATE messages SET data = NULL, data_size = NULL, signature = NULL WHERE id = ?",
|
|
)
|
|
.map_err(db_error)?;
|
|
|
|
if let Err(e) = del_st.execute(params![id]) {
|
|
error!("Couldn't delete message: {}.", e);
|
|
return Err(Error::DatabaseFailedInternally.into());
|
|
}
|
|
|
|
let json = models::StatusCode { status_code: StatusCode::OK.as_u16() };
|
|
Ok(warp::reply::json(&json).into_response())
|
|
}
|
|
|
|
// TODO FIXME -- the paging here is odd. See get_messages for details.
|
|
|
|
/// Returns either the last `limit` deleted messages or all deleted messages since
|
|
/// `from_server_id`, limited to `limit`.
|
|
pub fn get_deleted_messages(
|
|
query_params: HashMap<String, String>,
|
|
user: User,
|
|
room: Room,
|
|
) -> Result<Vec<models::DeletedMessage>, Rejection> {
|
|
let conn = storage::get_conn()?;
|
|
|
|
let (from_server_id, limit) = get_messages_params(&query_params);
|
|
|
|
require_authorization(
|
|
&conn,
|
|
&user,
|
|
&room,
|
|
AuthorizationRequired { read: true, ..Default::default() },
|
|
)?;
|
|
|
|
// Query the database
|
|
let mut st = conn.prepare_cached(if from_server_id.is_some() {
|
|
"SELECT updated, id FROM messages WHERE room = ?1 AND updated > ?2 AND data IS NULL ORDER BY updated LIMIT ?3"
|
|
} else {
|
|
"SELECT updated, id FROM messages WHERE room = ?1 AND data IS NULL ORDER BY updated DESC LIMIT ?3"
|
|
}).map_err(db_error)?;
|
|
let result = match st.query_map(params![from_server_id, limit], |row| {
|
|
Ok(models::DeletedMessage { updated: row.get(0)?, deleted_message_id: row.get(1)? })
|
|
}) {
|
|
Ok(rows) => rows,
|
|
Err(e) => {
|
|
error!("Couldn't get deleted messages: {}.", e);
|
|
return Err(Error::DatabaseFailedInternally.into());
|
|
}
|
|
}
|
|
.collect::<Result<Vec<models::DeletedMessage>, _>>()
|
|
.map_err(|_| Error::DatabaseFailedInternally.into());
|
|
return result;
|
|
}
|
|
|
|
// Moderation
|
|
|
|
pub fn add_moderator_public(
|
|
room: Room,
|
|
user: User,
|
|
session_id: &str,
|
|
admin: bool,
|
|
) -> Result<Response, Rejection> {
|
|
require_authorization(
|
|
&*storage::get_conn()?,
|
|
&user,
|
|
&room,
|
|
AuthorizationRequired { admin: true, ..Default::default() },
|
|
)?;
|
|
add_moderator_impl(session_id, admin, room)
|
|
}
|
|
|
|
// TODO: need ability to add *global* server moderators/admins (which, of course, can only be done
|
|
// by global server admins).
|
|
|
|
// Not publicly exposed.
|
|
pub async fn add_moderator(
|
|
body: models::ChangeModeratorRequestBody,
|
|
) -> Result<Response, Rejection> {
|
|
add_moderator_impl(
|
|
&body.session_id,
|
|
body.admin.unwrap_or(false),
|
|
storage::get_room_from_token(&*storage::get_conn()?, &body.room_token)?,
|
|
)
|
|
}
|
|
|
|
pub fn add_moderator_impl(
|
|
session_id: &str,
|
|
admin: bool,
|
|
room: Room,
|
|
) -> Result<Response, Rejection> {
|
|
require_session_id(session_id)?;
|
|
|
|
let mut conn = storage::get_conn()?;
|
|
let tx = storage::get_transaction(&mut conn)?;
|
|
|
|
if let Err(e) = tx
|
|
.prepare_cached("INSERT OR IGNORE INTO users (session_id) VALUES (?)")
|
|
.map_err(db_error)?
|
|
.execute(params![session_id])
|
|
{
|
|
error!("Failed to insert new user row for {}: {}", session_id, e);
|
|
return Err(Error::DatabaseFailedInternally.into());
|
|
}
|
|
|
|
let add_perm_query = format!(
|
|
"INSERT INTO user_permission_overrides (user, room, {mod_column})
|
|
VALUES ((SELECT id FROM users WHERE session_id = ?), ?, TRUE)
|
|
ON CONFLICT DO UPDATE SET {mod_column} = TRUE",
|
|
mod_column = if admin { "admin" } else { "moderator" }
|
|
);
|
|
if let Err(e) =
|
|
tx.prepare_cached(&add_perm_query).map_err(db_error)?.execute(params![session_id, room.id])
|
|
{
|
|
error!("Failed to insert new permission for {}: {}", session_id, e);
|
|
return Err(Error::DatabaseFailedInternally.into());
|
|
}
|
|
if let Err(e) = tx.commit() {
|
|
error!("Failed to commit new moderator transaction: {}", e);
|
|
return Err(Error::DatabaseFailedInternally.into());
|
|
}
|
|
|
|
// Return
|
|
info!("Added moderator: {} to room with ID: {}", &session_id, &room.token);
|
|
let json = models::StatusCode { status_code: StatusCode::OK.as_u16() };
|
|
Ok(warp::reply::json(&json).into_response())
|
|
}
|
|
|
|
pub fn delete_moderator_public(
|
|
session_id: &str,
|
|
user: User,
|
|
room: Room,
|
|
) -> Result<Response, Rejection> {
|
|
require_authorization(
|
|
&*storage::get_conn()?,
|
|
&user,
|
|
&room,
|
|
AuthorizationRequired { admin: true, ..Default::default() },
|
|
)?;
|
|
delete_moderator_impl(session_id, room)
|
|
}
|
|
|
|
// Not publicly exposed.
|
|
pub async fn delete_moderator(
|
|
body: models::ChangeModeratorRequestBody,
|
|
) -> Result<Response, Rejection> {
|
|
delete_moderator_impl(
|
|
&body.session_id,
|
|
storage::get_room_from_token(&*storage::get_conn()?, &body.room_token)?,
|
|
)
|
|
}
|
|
|
|
pub fn delete_moderator_impl(session_id: &str, room: Room) -> Result<Response, Rejection> {
|
|
require_session_id(session_id)?;
|
|
|
|
let conn = storage::get_conn()?;
|
|
let mut st = conn
|
|
.prepare_cached(
|
|
"UPDATE user_permission_overrides SET moderator = FALSE, admin = FALSE \
|
|
WHERE room = ? AND user = (SELECT id FROM users WHERE session_id = ?) AND (moderator OR admin)"
|
|
)
|
|
.map_err(db_error)?;
|
|
match st.execute(params![room.id, &session_id]) {
|
|
Err(e) => {
|
|
error!("Couldn't remove moderator {} from room {}: {}", session_id, room.token, e);
|
|
return Err(Error::DatabaseFailedInternally.into());
|
|
}
|
|
Ok(count) if count > 0 => {
|
|
info!("Removed moderator {} from room {}", session_id, room.token)
|
|
}
|
|
Ok(_count) => info!("{} is not a moderator of room {}", session_id, room.token),
|
|
}
|
|
|
|
let json = models::StatusCode { status_code: StatusCode::OK.as_u16() };
|
|
Ok(warp::reply::json(&json).into_response())
|
|
}
|
|
|
|
/// Returns the list of a room's publicly visible moderators (including admins).
|
|
pub fn get_moderators(
|
|
conn: &rusqlite::Connection,
|
|
user: &User,
|
|
room: &Room,
|
|
) -> Result<Vec<String>, Rejection> {
|
|
require_authorization(
|
|
conn,
|
|
user,
|
|
room,
|
|
AuthorizationRequired { read: true, ..Default::default() },
|
|
)?;
|
|
|
|
let mut st = conn
|
|
.prepare_cached(
|
|
"SELECT session_id FROM user_permissions WHERE room = ? AND moderator AND visible_mod",
|
|
)
|
|
.map_err(db_error)?;
|
|
|
|
let ids: Result<Vec<String>, _> = match st.query_map(params![room.id], |row| row.get(0)) {
|
|
Ok(row) => row,
|
|
Err(e) => {
|
|
error!("Couldn't query database: {}.", e);
|
|
return Err(Error::DatabaseFailedInternally.into());
|
|
}
|
|
}
|
|
.collect();
|
|
ids.map_err(|_| Error::DatabaseFailedInternally.into())
|
|
}
|
|
|
|
// FIXME: we also need server-level ban controls, along with message deletion abilities.
|
|
|
|
// FIXME: we need the ability to remove read/write/upload permissions without banning.
|
|
|
|
// FIXME: we need an ability to set a time-expiring limitation
|
|
|
|
/// Bans the given `public_key`, optionally also deleting all the user's messages and uploaded
|
|
/// files. Requires a moderator for a regular user, and admin for a moderator/admin.
|
|
pub async fn ban(
|
|
session_id: &str,
|
|
delete_all: bool,
|
|
user: &User,
|
|
room: &Room,
|
|
) -> Result<Response, Rejection> {
|
|
if !is_session_id(&session_id) {
|
|
warn!("Ignoring ban request: invalid session_id.");
|
|
return Err(Error::ValidationFailed.into());
|
|
}
|
|
|
|
let mut auth = AuthorizationRequired { moderator: true, ..Default::default() };
|
|
|
|
let mut conn = storage::get_conn()?;
|
|
let tx = storage::get_transaction(&mut conn)?;
|
|
|
|
tx.prepare_cached("INSERT OR IGNORE INTO users (session_id) VALUES (?)")
|
|
.map_err(db_error)?
|
|
.execute(params![session_id])
|
|
.map_err(db_error)?;
|
|
|
|
let userid: i64;
|
|
|
|
match tx
|
|
.prepare_cached(
|
|
"SELECT user, moderator, global_moderator FROM user_permissions \
|
|
WHERE room = ? AND session_id = ?",
|
|
)
|
|
.map_err(db_error)?
|
|
.query_row(params![room.id, session_id], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
|
{
|
|
Ok((uid, is_mod, is_global_mod)) => {
|
|
if is_global_mod {
|
|
warn!("Cannot ban {} from {}: user is a global moderator", session_id, room.token);
|
|
return Err(Error::Unauthorized.into());
|
|
}
|
|
userid = uid;
|
|
if is_mod {
|
|
// To ban a moderator we have to be a room admin, not just a moderator.
|
|
auth.admin = true;
|
|
}
|
|
}
|
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
|
let response = json!({"status_code": StatusCode::NOT_FOUND.as_u16()});
|
|
return Ok(warp::reply::json(&response).into_response());
|
|
}
|
|
Err(_) => return Err(Error::DatabaseFailedInternally.into()),
|
|
};
|
|
|
|
require_authorization(&tx, user, room, auth)?;
|
|
|
|
if let Err(e) = tx
|
|
.prepare_cached(
|
|
"\
|
|
INSERT INTO user_permission_overrides (room, user, banned, moderator, admin) \
|
|
VALUES (?, ?, TRUE, FALSE, FALSE) \
|
|
ON CONFLICT DO UPDATE SET banned = TRUE, moderator = FALSE, admin = FALSE
|
|
",
|
|
)
|
|
.map_err(db_error)?
|
|
.execute(params![room.id, userid])
|
|
{
|
|
error!("Failed to insert ban for {} in {}: {}", session_id, room.token, e);
|
|
return Err(Error::DatabaseFailedInternally.into());
|
|
}
|
|
|
|
let mut posts_removed = 0;
|
|
let mut files_removed = 0;
|
|
if delete_all {
|
|
posts_removed += match tx
|
|
.prepare_cached(
|
|
"UPDATE messages SET data = NULL, data_size = NULL, signature = NULL \
|
|
WHERE room = ? AND user = ?",
|
|
)
|
|
.map_err(db_error)?
|
|
.execute(params![room.id, userid])
|
|
{
|
|
Ok(count) => count,
|
|
Err(e) => {
|
|
error!("Failed to delete posts by {} from {}: {}", session_id, room.token, e);
|
|
return Err(Error::DatabaseFailedInternally.into());
|
|
}
|
|
};
|
|
|
|
// We don't actually delete from disk right now, but clear the room (so that they aren't
|
|
// retrievable) and set them to be expired (so that the next file pruning will delete them
|
|
// from disk).
|
|
files_removed = tx
|
|
.prepare_cached(
|
|
"UPDATE files SET room = NULL, expiry = ? WHERE room = ? AND uploader = ?",
|
|
)
|
|
.map_err(db_error)?
|
|
.execute(params![unixtime_f64(), room.id, userid])
|
|
.map_err(db_error)?;
|
|
}
|
|
|
|
if let Err(e) = tx.commit() {
|
|
info!("Failed to ban/delete user {} from {}: {}", session_id, room.token, e);
|
|
return Err(Error::DatabaseFailedInternally.into());
|
|
}
|
|
|
|
info!(
|
|
"Banned {} from room {}: {} messages and {} files deleted",
|
|
session_id, room.token, posts_removed, files_removed
|
|
);
|
|
|
|
Ok(warp::reply::json(&models::StatusCode { status_code: StatusCode::OK.as_u16() })
|
|
.into_response())
|
|
}
|
|
|
|
/// Unbans the given `public_key` if the requesting user is a moderator.
|
|
pub fn unban(session_id: &str, user: &User, room: &Room) -> Result<Response, Rejection> {
|
|
if !is_session_id(&session_id) {
|
|
warn!("Ignoring unban request: invalid session_id.");
|
|
return Err(Error::ValidationFailed.into());
|
|
}
|
|
|
|
let conn = storage::get_conn()?;
|
|
require_authorization(
|
|
&conn,
|
|
user,
|
|
room,
|
|
AuthorizationRequired { moderator: true, ..Default::default() },
|
|
)?;
|
|
|
|
let count = match conn
|
|
.prepare_cached(
|
|
"UPDATE user_permission_overrides SET banned = FALSE \
|
|
WHERE room = ? AND user IN (SELECT id FROM users WHERE session_id = ?)",
|
|
)
|
|
.map_err(db_error)?
|
|
.execute(params![room.id, session_id])
|
|
{
|
|
Ok(count) => count,
|
|
Err(e) => {
|
|
error!("Failed to unban user: {}", e);
|
|
return Err(Error::DatabaseFailedInternally.into());
|
|
}
|
|
};
|
|
|
|
let status_code =
|
|
if count > 0 { StatusCode::OK.as_u16() } else { StatusCode::NOT_FOUND.as_u16() };
|
|
Ok(warp::reply::json(&models::StatusCode { status_code }).into_response())
|
|
}
|
|
|
|
// FIXME: this list was obtainable by *anyone* with basic access. This seemed wrong, so I changed
|
|
// it to require moderator access to get that list. We need to verify that doesn't break existing
|
|
// Session versions -- and if it does, stick in a hack, perhaps returning an empty list if not a
|
|
// moderator. (Or perhaps a list that can only include yourself, so that you can see your own ban
|
|
// but no one else's).
|
|
|
|
/// Returns the full list of banned public keys.
|
|
pub fn get_banned_public_keys(user: &User, room: &Room) -> Result<Response, Rejection> {
|
|
let conn = storage::get_conn()?;
|
|
require_authorization(
|
|
&conn,
|
|
user,
|
|
room,
|
|
AuthorizationRequired { moderator: true, ..Default::default() },
|
|
)?;
|
|
|
|
let banned_members: Result<Vec<String>, _> = match conn
|
|
.prepare_cached("SELECT session_id FROM user_permissions WHERE room = ? AND banned")
|
|
.map_err(db_error)?
|
|
.query_map(params![room.id], |row| row.get(0))
|
|
{
|
|
Ok(rows) => rows,
|
|
Err(e) => {
|
|
error!("Couldn't query database: {}.", e);
|
|
return Err(Error::DatabaseFailedInternally.into());
|
|
}
|
|
}
|
|
.collect();
|
|
|
|
Ok(warp::reply::json(&json!({
|
|
"status_code": StatusCode::OK.as_u16(),
|
|
"banned_members": banned_members.map_err(db_error)?
|
|
}))
|
|
.into_response())
|
|
}
|
|
|
|
// General
|
|
|
|
/// Returns members who have accessed the given room at least once in the past 7 days.
|
|
pub fn get_member_count(user: User, room: Room) -> Result<Response, Rejection> {
|
|
return get_member_count_since(user, room, ROOM_DEFAULT_ACTIVE_THRESHOLD);
|
|
}
|
|
|
|
// FIXME: wire this up in API call so that callers can specify a threshold timeout
|
|
pub fn get_member_count_since(
|
|
user: User,
|
|
room: Room,
|
|
ago: Duration,
|
|
) -> Result<Response, Rejection> {
|
|
let conn = storage::get_conn()?;
|
|
require_authorization(
|
|
&conn,
|
|
&user,
|
|
&room,
|
|
AuthorizationRequired { read: true, ..Default::default() },
|
|
)?;
|
|
|
|
let mut st = conn
|
|
.prepare_cached("SELECT COUNT(*) FROM room_users WHERE room = ? AND last_active >= ?")
|
|
.map_err(db_error)?;
|
|
let cutoff =
|
|
(SystemTime::now() - ago).duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs_f64();
|
|
let users = match st.query_row(params![room.id, cutoff], |row| Ok(row.get::<_, i64>(0)?)) {
|
|
Ok(count) => count,
|
|
Err(e) => {
|
|
error!("Couldn't query database: {}.", e);
|
|
return Err(warp::reject::custom(Error::DatabaseFailedInternally));
|
|
}
|
|
};
|
|
let response = json!({ "status_code": StatusCode::OK.as_u16(), "member_count": users });
|
|
Ok(warp::reply::json(&response).into_response())
|
|
}
|
|
|
|
/// Polls a room for metadata (name, description, image, moderators) if metadata has been updated
|
|
/// since the given update value (or always, if since_update is omitted).
|
|
pub fn poll_room_metadata(user: &User, room: Room, since_update: Option<i64>) {
|
|
// FIXME TODO
|
|
panic!("FIXME TODO");
|
|
}
|
|
|
|
/// Polls a room for new/updated/deleted messages posted since a given update id.
|
|
pub fn get_room_updates(user: User, room: Room, since_update: i64) {
|
|
// FIXME TODO - implement this
|
|
panic!("FIXME TODO");
|
|
|
|
/*
|
|
debug!("Got unified poll request for room {} since update {}", room.token, since);
|
|
response.messages = get_msg_updates.query_map(params![room.id, since], Message::from_row)
|
|
.map_err(db_error)?
|
|
.collect::<Result<Vec<models::Message>, _>>()
|
|
.map_err(db_error)?;
|
|
|
|
// Gets a list of new, updated, and deleted messages since a given room update value.
|
|
let mut get_msg_updates = tx.prepare_cached(
|
|
"SELECT * FROM message_details WHERE room = ? AND updated > ? ORDER BY updated LIMIT 250")
|
|
.map_err(db_error)?;
|
|
|
|
*/
|
|
}
|
|
|
|
/// Deprecated room polling; unlike the above, this does not handle metadata (except for
|
|
/// moderators, which are *always* included even though they rarely change), does not support
|
|
/// message edits, and has non-obvious alternate modes of operation.
|
|
pub fn compact_poll(
|
|
user: Option<User>,
|
|
request_bodies: Vec<models::CompactPollRequestBody>,
|
|
) -> Result<Response, Rejection> {
|
|
let mut response_bodies = Vec::<models::CompactPollResponseBody>::new();
|
|
|
|
let mut conn = storage::get_conn()?;
|
|
let tx = storage::get_transaction(&mut conn)?;
|
|
|
|
let mut rooms = HashMap::<String, Option<Room>>::new();
|
|
for r in &request_bodies {
|
|
rooms.insert(r.room_token.clone(), None);
|
|
}
|
|
|
|
if !rooms.is_empty() {
|
|
let query =
|
|
format!("SELECT * FROM rooms WHERE token IN (?{})", ",?".repeat(rooms.len() - 1));
|
|
for r in tx
|
|
.prepare_cached(&query)
|
|
.map_err(db_error)?
|
|
.query_map(params_from_iter(rooms.keys()), Room::from_row)
|
|
.map_err(db_error)?
|
|
{
|
|
let room = r.map_err(db_error)?;
|
|
rooms.insert(room.token.clone(), Some(room));
|
|
}
|
|
}
|
|
|
|
{
|
|
// Gets a list of recent room messages: Session fires this when first joining a room.
|
|
let mut get_recent_messages = tx
|
|
.prepare_cached(
|
|
"SELECT * FROM message_details \
|
|
WHERE room = ? AND data IS NOT NULL \
|
|
ORDER BY id DESC LIMIT 256",
|
|
)
|
|
.map_err(db_error)?;
|
|
|
|
let mut get_recent_deletions = tx
|
|
.prepare_cached(
|
|
"SELECT id, updated FROM messages \
|
|
WHERE room = ? AND data IS NULL \
|
|
ORDER BY updated DESC LIMIT 256",
|
|
)
|
|
.map_err(db_error)?;
|
|
|
|
// Queries for actual polling, where we have an ID
|
|
let mut get_deleted_msgs = tx
|
|
.prepare_cached(
|
|
"SELECT id, updated FROM messages \
|
|
WHERE room = ? AND updated > ? AND data IS NULL \
|
|
ORDER BY updated LIMIT 256",
|
|
)
|
|
.map_err(db_error)?;
|
|
|
|
let mut get_msgs_since = tx
|
|
.prepare_cached(
|
|
"SELECT * FROM message_details \
|
|
WHERE room = ? AND id > ? AND data IS NOT NULL \
|
|
ORDER BY id LIMIT 256",
|
|
)
|
|
.map_err(db_error)?;
|
|
|
|
for request in request_bodies {
|
|
let mut response = models::CompactPollResponseBody {
|
|
room_token: request.room_token.clone(),
|
|
status_code: StatusCode::OK.as_u16(),
|
|
messages: vec![],
|
|
deletions: vec![],
|
|
moderators: vec![],
|
|
};
|
|
|
|
let room: &Room = match rooms.get(&request.room_token) {
|
|
Some(Some(room)) => room,
|
|
_ => {
|
|
response.status_code = StatusCode::NOT_FOUND.as_u16();
|
|
response_bodies.push(response);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let token_user: User;
|
|
let user = match user {
|
|
Some(ref u) => &u,
|
|
None => {
|
|
token_user =
|
|
get_user_from_token(&tx, &request.auth_token.ok_or(Error::Unauthorized)?)?;
|
|
&token_user
|
|
}
|
|
};
|
|
|
|
// Require read authorization, otherwise return a failure response for this
|
|
// sub-request.
|
|
let read_auth = AuthorizationRequired { read: true, ..Default::default() };
|
|
if let Err(e) = require_authorization(&tx, &user, &room, read_auth) {
|
|
response.status_code = super::errors::status_code(e.into()).as_u16();
|
|
response_bodies.push(response);
|
|
continue;
|
|
}
|
|
|
|
// Older Session clients ask us for messages since some ID & deletions since some
|
|
// deletion ID, and can't handle edits at all:
|
|
response.messages = if let Some(mut since) = request.from_message_server_id {
|
|
debug!(
|
|
"Got deprecated poll request for room {} messages since {}",
|
|
room.token, since
|
|
);
|
|
// If this is an imported database then we might have room id maps for "since-id"
|
|
// requests made from a client before the migration, and if so, we need to offset
|
|
// the requested id.
|
|
if let Some(hacks) = storage::ROOM_IMPORT_HACKS.as_ref() {
|
|
if let Some(map) = hacks.get(&room.id) {
|
|
if since <= map.max {
|
|
since += map.offset;
|
|
}
|
|
}
|
|
}
|
|
get_msgs_since.query_map(params![room.id, since], OldMessage::from_row)
|
|
} else {
|
|
debug!(
|
|
"Deprecated request without from; returning recent messages for {}",
|
|
room.token
|
|
);
|
|
get_recent_messages.query_map(params![room.id], OldMessage::from_row)
|
|
}
|
|
.map_err(db_error)?
|
|
.collect::<Result<Vec<models::OldMessage>, _>>()
|
|
.map_err(db_error)?;
|
|
|
|
let make_delmsg = |row: &rusqlite::Row| {
|
|
Ok(models::DeletedMessage { deleted_message_id: row.get(0)?, updated: row.get(1)? })
|
|
};
|
|
|
|
response.deletions = match request.from_deletion_server_id {
|
|
Some(since) => {
|
|
debug!("Deprecated poll request for {} deletions since {}", room.token, since);
|
|
get_deleted_msgs.query_map(params![room.id, since], make_delmsg)
|
|
}
|
|
_ => {
|
|
debug!("Deprecated poll request for recent {} deletions", room.token);
|
|
get_recent_deletions.query_map(params![room.id], make_delmsg)
|
|
}
|
|
}
|
|
.map_err(db_error)?
|
|
.collect::<Result<Vec<models::DeletedMessage>, _>>()
|
|
.map_err(db_error)?;
|
|
|
|
// Get the moderators.
|
|
response.moderators = match get_moderators(&tx, &user, &room) {
|
|
Ok(moderators) => moderators,
|
|
Err(e) => {
|
|
response.status_code = super::errors::status_code(e).as_u16();
|
|
response_bodies.push(response);
|
|
continue;
|
|
}
|
|
};
|
|
// We *also* include the requesting user if she is a global moderator/admin -- this
|
|
// isn't part of the room's moderator list, but older Session relies on seeing itself
|
|
// in this list to enable moderator capabilities.
|
|
if user.moderator || user.admin {
|
|
response.moderators.push(user.session_id.clone());
|
|
}
|
|
|
|
response_bodies.push(response);
|
|
}
|
|
}
|
|
|
|
if let Err(e) = tx.commit() {
|
|
error!("Compact poll queries failed: {}", e);
|
|
return Err(Error::DatabaseFailedInternally.into());
|
|
}
|
|
|
|
let response = json!({ "status_code": StatusCode::OK.as_u16(), "results": response_bodies });
|
|
Ok(warp::reply::json(&response).into_response())
|
|
}
|
|
|
|
// Not publicly exposed.
|
|
pub async fn get_url() -> Result<Response, Rejection> {
|
|
let url = super::get_url();
|
|
Ok(warp::reply::json(&url).into_response())
|
|
}
|
|
|
|
pub async fn get_session_version(platform: &str) -> Result<String, Rejection> {
|
|
let mut session_versions = SESSION_VERSIONS.read().clone();
|
|
let now = chrono::Utc::now().timestamp();
|
|
if let Some(version_info) = session_versions.get(platform) {
|
|
let last_updated = version_info.0;
|
|
if now - last_updated < SESSION_VERSION_UPDATE_INTERVAL {
|
|
let tag = version_info.1.to_string();
|
|
println!("Returning cached value: {}", tag);
|
|
return Ok(tag);
|
|
}
|
|
}
|
|
let octocrab = octocrab::instance();
|
|
let repo = format!("session-{}", platform);
|
|
let handler = octocrab.repos("oxen-io", repo);
|
|
let release = handler.releases().get_latest().await.unwrap();
|
|
let tag = release.tag_name;
|
|
let tuple = (now, tag.clone());
|
|
session_versions.insert(platform.to_string(), tuple);
|
|
*SESSION_VERSIONS.write() = session_versions.clone();
|
|
Ok(tag)
|
|
}
|
|
|
|
// not publicly exposed.
|
|
pub async fn get_stats_for_room(
|
|
room_token: String,
|
|
query_map: HashMap<String, i64>,
|
|
) -> Result<Response, Rejection> {
|
|
let window = *query_map.get("window").unwrap_or(&3600) as f64;
|
|
let upperbound = match query_map.get("start") {
|
|
Some(ts) => *ts as f64,
|
|
None => unixtime_f64(),
|
|
};
|
|
let lowerbound = upperbound - window;
|
|
|
|
let mut conn = storage::get_conn()?;
|
|
let tx = storage::get_transaction(&mut conn)?;
|
|
|
|
let room = storage::get_room_from_token(&tx, &room_token)?;
|
|
|
|
let active = tx
|
|
.prepare_cached(
|
|
"SELECT COUNT(*) FROM room_users WHERE room = ? AND last_active BETWEEN ? AND ?",
|
|
)
|
|
.map_err(db_error)?
|
|
.query_row(params![room.id, lowerbound, upperbound], |row| Ok(row.get::<_, i64>(0)?))
|
|
.map_err(db_error)?;
|
|
|
|
let posts = tx
|
|
.prepare_cached("SELECT COUNT(*) FROM messages WHERE room = ? AND posted BETWEEN ? AND ?")
|
|
.map_err(db_error)?
|
|
.query_row(params![room.id, lowerbound, upperbound], |row| Ok(row.get::<_, i64>(0)?))
|
|
.map_err(db_error)?;
|
|
|
|
// FIXME: DRY this structure
|
|
let response = json!({
|
|
"active_users": active,
|
|
"posts": posts });
|
|
Ok(warp::reply::json(&response).into_response())
|
|
}
|
|
|
|
// Utilities
|
|
|
|
fn is_session_id(session_id: &str) -> bool {
|
|
session_id.len() == 66
|
|
&& &session_id[0..2] == "05"
|
|
&& session_id.find(|c: char| !c.is_ascii_hexdigit()).is_none()
|
|
}
|
|
|
|
pub fn require_session_id(session_id: &str) -> Result<(), Error> {
|
|
if !is_session_id(session_id) {
|
|
warn!("'{}' is not a valid Session id", session_id);
|
|
return Err(Error::ValidationFailed);
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
/// Verify that the user has the given permissions for the given room (and isn't banned), and
|
|
/// updates the last activity record for the user in the room. Returns an Error::Unauthorized if
|
|
/// the user does not have the required permissions, Error::DatabaseFailedInternally on database
|
|
/// error, and an empty tuple on success.
|
|
///
|
|
/// `conn` is expected to be either a connection or an open transaction.
|
|
fn require_authorization(
|
|
conn: &rusqlite::Connection,
|
|
user: &User,
|
|
room: &Room,
|
|
req: AuthorizationRequired,
|
|
) -> Result<(), Error> {
|
|
require_authorization_impl(conn, &user, &room, req, true)
|
|
}
|
|
/// Same as above, but does not update the room/user last activity timestamp.
|
|
#[allow(dead_code)]
|
|
fn require_authorization_no_activity(
|
|
conn: &rusqlite::Connection,
|
|
user: &User,
|
|
room: &Room,
|
|
req: AuthorizationRequired,
|
|
) -> Result<(), Error> {
|
|
return require_authorization_impl(conn, &user, &room, req, false);
|
|
}
|
|
|
|
fn require_authorization_impl(
|
|
conn: &rusqlite::Connection,
|
|
user: &User,
|
|
room: &Room,
|
|
need: AuthorizationRequired,
|
|
log_active: bool,
|
|
) -> Result<(), Error> {
|
|
let mut st = conn
|
|
.prepare_cached(
|
|
"SELECT banned, read, write, upload, moderator, admin FROM user_permissions WHERE room = ? AND user = ?"
|
|
)
|
|
.map_err(db_error)?;
|
|
|
|
match st.query_row(params![room.id, user.id], |row| {
|
|
let banned: bool = row.get(0)?;
|
|
let read: bool = row.get(1)?;
|
|
let write: bool = row.get(2)?;
|
|
let upload: bool = row.get(3)?;
|
|
let moderator: bool = row.get(4)?;
|
|
let admin: bool = row.get(5)?;
|
|
return Ok(if admin {
|
|
true
|
|
} else if need.admin {
|
|
false
|
|
} else if moderator {
|
|
true
|
|
} else if need.moderator {
|
|
false
|
|
} else {
|
|
!banned && (!need.read || read) && (!need.write || write) && (!need.upload || upload)
|
|
});
|
|
}) {
|
|
Ok(allowed) => {
|
|
if !allowed {
|
|
return Err(Error::Unauthorized);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
error!("Couldn't query permissions from database: {}", e);
|
|
return Err(Error::DatabaseFailedInternally);
|
|
}
|
|
};
|
|
|
|
if log_active {
|
|
conn.prepare_cached(
|
|
"INSERT INTO room_users (user, room) VALUES (?, ?)
|
|
ON CONFLICT DO UPDATE SET last_active = ((julianday('now') - 2440587.5)*86400.0)"
|
|
)
|
|
.map_err(db_error)?
|
|
.execute(params![user.id, room.id])
|
|
.map_err(db_error)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn unixtime_f64() -> f64 {
|
|
SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs_f64()
|
|
}
|