Working on upload images and tilemap

This commit is contained in:
Benjamin Sherriff
2023-10-23 16:17:07 -04:00
parent 8b4d4e1b1f
commit 3eb888b57d
22 changed files with 987 additions and 656 deletions

View File

@@ -29,6 +29,7 @@ jsonwebtoken = "9.0.0"
redis = { version = "0.23.3", features = ["tokio-comp", "connection-manager", "r2d2"] } redis = { version = "0.23.3", features = ["tokio-comp", "connection-manager", "r2d2"] }
base64 = "0.21.4" base64 = "0.21.4"
rust-s3 = "0.33.0" rust-s3 = "0.33.0"
actix-multipart = "0.6.1"
[dependencies.tokio] [dependencies.tokio]
version = "1.32.0" version = "1.32.0"

View File

@@ -6,5 +6,6 @@ CREATE TABLE IF NOT EXISTS users (
last_name TEXT NOT NULL, last_name TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(), created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
profile TEXT,
verified BOOLEAN NOT NULL DEFAULT FALSE verified BOOLEAN NOT NULL DEFAULT FALSE
); );

View File

@@ -29,6 +29,7 @@ impl RegisterUser {
last_name: self.last_name, last_name: self.last_name,
updated_at: chrono::Utc::now().naive_utc(), updated_at: chrono::Utc::now().naive_utc(),
created_at: chrono::Utc::now().naive_utc(), created_at: chrono::Utc::now().naive_utc(),
profile: None,
verified: false, verified: false,
}) })
} }
@@ -50,6 +51,7 @@ pub struct QueryUser {
pub last_name: String, pub last_name: String,
pub updated_at: chrono::NaiveDateTime, pub updated_at: chrono::NaiveDateTime,
pub created_at: chrono::NaiveDateTime, pub created_at: chrono::NaiveDateTime,
pub profile: Option<String>,
pub verified: bool, pub verified: bool,
} }
@@ -75,6 +77,7 @@ pub struct InsertUser {
pub last_name: String, pub last_name: String,
pub updated_at: chrono::NaiveDateTime, pub updated_at: chrono::NaiveDateTime,
pub created_at: chrono::NaiveDateTime, pub created_at: chrono::NaiveDateTime,
pub profile: Option<String>,
pub verified: bool, pub verified: bool,
} }
@@ -86,6 +89,15 @@ impl InsertUser {
.get_result(&mut conn)?; .get_result(&mut conn)?;
Ok(user) Ok(user)
} }
pub fn update_profile(email: &str, profile: Option<&str>) -> Result<QueryUser, ServiceError> {
let mut conn = connection()?;
let user = diesel::update(users::table)
.filter(users::email.eq(&email))
.set(users::profile.eq(profile))
.get_result(&mut conn)?;
Ok(user)
}
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]

View File

@@ -114,7 +114,12 @@ impl From<redis::RedisError> for ServiceError {
impl From<s3::error::S3Error> for ServiceError { impl From<s3::error::S3Error> for ServiceError {
fn from(error: s3::error::S3Error) -> ServiceError { fn from(error: s3::error::S3Error) -> ServiceError {
ServiceError::new(500, format!("Unknown s3 error: {}", error)) match error {
s3::error::S3Error::Http(code, message) => {
ServiceError::new(code, message)
},
_ => ServiceError::new(500, format!("Unknown s3 error: {}", error))
}
} }
} }

View File

@@ -22,6 +22,7 @@ mod auth;
mod dnd; mod dnd;
mod bot; mod bot;
mod storage; mod storage;
mod users;
#[actix_web::main] #[actix_web::main]
async fn main() -> std::io::Result<()> { async fn main() -> std::io::Result<()> {
@@ -125,8 +126,9 @@ async fn main() -> std::io::Result<()> {
.wrap(cors) .wrap(cors)
.app_data(web::Data::new(Arc::clone(&app_data))) .app_data(web::Data::new(Arc::clone(&app_data)))
.configure(crate::storage::messages::init_routes) .configure(crate::storage::messages::init_routes)
.configure(crate::dnd::spells::init_routes)
.configure(crate::auth::init_routes) .configure(crate::auth::init_routes)
.configure(crate::users::init_routes)
.configure(crate::dnd::spells::init_routes)
.configure(crate::bot::api::init_routes) .configure(crate::bot::api::init_routes)
}) })
.bind(format!("{}:{}", host, port)) { .bind(format!("{}:{}", host, port)) {

View File

@@ -112,6 +112,12 @@ pub async fn upload_file(path: &str, content: &[u8]) -> Result<ResponseData, Ser
Ok(response) Ok(response)
} }
pub async fn get_file(path: &str) -> Result<Vec<u8>, ServiceError> {
let response = BUCKET.get_object(path).await?;
let bytes = response.bytes();
Ok(bytes.to_vec())
}
pub async fn delete_file(path: &str) -> Result<ResponseData, ServiceError> { pub async fn delete_file(path: &str) -> Result<ResponseData, ServiceError> {
let response = BUCKET.delete_object(path).await?; let response = BUCKET.delete_object(path).await?;
Ok(response) Ok(response)

View File

@@ -48,6 +48,7 @@ diesel::table! {
last_name -> Text, last_name -> Text,
updated_at -> Timestamp, updated_at -> Timestamp,
created_at -> Timestamp, created_at -> Timestamp,
profile -> Nullable<Text>,
verified -> Bool, verified -> Bool,
} }
} }

3
service/src/users/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
mod routes;
pub use routes::init_routes;

136
service/src/users/routes.rs Normal file
View File

@@ -0,0 +1,136 @@
use actix_multipart::Multipart;
use actix_web::{web, HttpResponse, post, delete, get, ResponseError};
use log::error;
use serenity::futures::StreamExt;
use siren::ServiceError;
use crate::{auth::{JwtAuth, InsertUser, QueryUser}, storage::{upload_file, get_file, delete_file}};
#[post("/picture")]
async fn set_picture(mut payload: Multipart, auth: JwtAuth) -> HttpResponse {
while let Some(item) = payload.next().await {
let mut bytes = web::BytesMut::new();
let mut field = match item {
Ok(field) => field,
Err(err) => return ResponseError::error_response(&err)
};
let content_type = field.content_disposition();
// Get file name and construct the file path
let file_name = match content_type.get_filename() {
Some(name) => {
// Verify extension is supported
match name.split(".").last() {
Some(ext) => {
match ext {
"png" | "jpg" | "jpeg" => name,
_ => return ResponseError::error_response(&ServiceError {
status: 400,
message: "File extension is not supported".to_string()
})
}
},
None => return ResponseError::error_response(&ServiceError {
status: 400,
message: "Unknown file extension".to_string()
})
}
},
None => return ResponseError::error_response(&ServiceError {
status: 400,
message: "File name is not provided".to_string()
})
};
let path = format!("users/{}/{}", auth.user.email, file_name);
// Build the file and store it in minio
while let Some(chunk) = field.next().await {
let data = match chunk {
Ok(data) => data,
Err(err) => {
error!("Failed to get chunk: {}", err);
return ResponseError::error_response(&err);
}
};
bytes.extend_from_slice(&data);
}
match upload_file(&path, &bytes).await {
Ok(_) => {
match InsertUser::update_profile(&auth.user.email, Some(&path)) {
Ok(_) => {}
Err(err) => {
error!("Failed to update user profile: {}", err);
return ResponseError::error_response(&err);
}
};
},
Err(err) => {
error!("Failed to upload file: {}", err);
return ResponseError::error_response(&err);
}
}
};
return HttpResponse::Ok().finish();
}
#[get("/picture")]
async fn get_picture(auth: JwtAuth) -> HttpResponse {
let user = match QueryUser::get_by_email(&auth.user.email) {
Ok(user) => user,
Err(err) => {
error!("Failed to get user: {}", err);
return ResponseError::error_response(&err);
}
};
if let Some(path) = user.profile {
match get_file(&path).await {
Ok(bytes) => return HttpResponse::Ok().body(bytes),
Err(err) => {
error!("Failed to get file: {}", err);
return ResponseError::error_response(&err);
}
}
} else {
return HttpResponse::NotFound().finish();
}
}
#[delete("/picture")]
async fn delete_picture(auth: JwtAuth) -> HttpResponse {
match QueryUser::get_by_email(&auth.user.email) {
Ok(user) => {
match user.profile {
Some(path) => {
match delete_file(&path).await {
Ok(_) => {
match InsertUser::update_profile(&auth.user.email, None) {
Ok(_) => {}
Err(err) => {
error!("Failed to update user profile: {}", err);
return ResponseError::error_response(&err);
}
};
}
Err(err) => {
error!("Failed to delete file: {}", err);
return ResponseError::error_response(&err);
}
};
},
None => {}
}
},
Err(err) => {
error!("Failed to get user: {}", err);
return ResponseError::error_response(&err);
}
};
return HttpResponse::Ok().finish();
}
pub fn init_routes(config: &mut web::ServiceConfig) {
config.service(web::scope("users")
.service(set_picture)
.service(get_picture)
.service(delete_picture)
);
}

1278
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,31 +9,30 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@mantine/core": "^7.1.2", "@mantine/core": "^7.1.5",
"@mantine/form": "^7.1.2", "@mantine/form": "^7.1.5",
"@mantine/hooks": "^7.1.2", "@mantine/hooks": "^7.1.5",
"@mantine/modals": "^7.1.2", "@mantine/modals": "^7.1.5",
"@mantine/notifications": "^7.1.2", "@mantine/notifications": "^7.1.5",
"axios": "^1.5.1", "@pixi/react": "^7.1.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"next": "^13.5.4", "next": "^13.5.6",
"pixi.js": "^7.3.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-icons": "^4.11.0", "react-icons": "^4.11.0",
"react-leaflet": "^4.2.1",
"recharts": "^2.8.0",
"recoil": "^0.7.7" "recoil": "^0.7.7"
}, },
"devDependencies": { "devDependencies": {
"@types/js-cookie": "^3.0.4", "@types/js-cookie": "^3.0.5",
"@types/node": "20.8.2", "@types/node": "20.8.7",
"@types/react": "18.2.24", "@types/react": "18.2.31",
"@types/react-dom": "18.2.8", "@types/react-dom": "18.2.14",
"@typescript-eslint/eslint-plugin": "^6.7.4", "@typescript-eslint/eslint-plugin": "^6.8.0",
"@typescript-eslint/parser": "^6.7.4", "@typescript-eslint/parser": "^6.8.0",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"eslint": "8.50.0", "eslint": "8.52.0",
"eslint-config-next": "13.5.4", "eslint-config-next": "13.5.6",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0", "eslint-plugin-prettier": "^5.0.0",
"postcss": "^8.4.31", "postcss": "^8.4.31",

View File

@@ -25,7 +25,7 @@ export async function logout() {
} }
export async function refresh(refresh_token_rotation?: boolean): Promise<ResponseAuth | undefined> { export async function refresh(refresh_token_rotation?: boolean): Promise<ResponseAuth | undefined> {
const response = await get('auth/refresh', { params: { refresh_token_rotation } }); const response = await get('auth/refresh', { refresh_token_rotation });
if (response?.status === 200) { if (response?.status === 200) {
return response.json(); return response.json();
} else { } else {

View File

@@ -8,7 +8,7 @@ export async function get(endpoint: string, params: Record<string, any> = {}): P
// Remove undefined params // Remove undefined params
Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]); Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]);
const urlParams = new URLSearchParams(params); const urlParams = new URLSearchParams(params);
const url = urlParams ? `${baseURL}/${endpoint}?${urlParams}` : `${baseURL}/${endpoint}`; const url = urlParams && urlParams.size > 0 ? `${baseURL}/${endpoint}?${urlParams}` : `${baseURL}/${endpoint}`;
const response = await fetch(url, { const response = await fetch(url, {
method: 'GET', method: 'GET',
credentials: 'include' credentials: 'include'
@@ -16,11 +16,15 @@ export async function get(endpoint: string, params: Record<string, any> = {}): P
return response; return response;
} }
export async function post(endpoint: string, body = {}): Promise<Response> { interface PostOptions {
headers?: Record<string, any>;
}
export async function post(endpoint: string, body = {}, options?: PostOptions): Promise<Response> {
const url = `${baseURL}/${endpoint}`; const url = `${baseURL}/${endpoint}`;
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: { headers: options?.headers || {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
credentials: 'include', credentials: 'include',

24
ui/src/api/users.ts Normal file
View File

@@ -0,0 +1,24 @@
import { get, post } from '.';
export async function getPicture(): Promise<Blob | undefined> {
const response = await get('users/picture');
if (response?.status === 200) {
return response.blob();
} else {
return undefined;
}
}
export async function setPicture(payload: File): Promise<boolean> {
const data = new FormData();
data.append('file', payload);
// TODO: Figure out why the form data object is empty
const response = await post('users/picture', data, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (response?.status === 200) {
return true;
} else {
return false;
}
}

View File

@@ -22,15 +22,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<head> <head>
<title>Siren</title> <title>Siren</title>
</head> </head>
<body className={`${inter.className} wrapper h-full`}> <body className={`${inter.className} wrapper`}>
<RecoilRootWrapper> <RecoilRootWrapper>
<MantineProvider> <MantineProvider>
<Notifications /> <Notifications />
<ModalsProvider> <ModalsProvider>
<Header /> <Header />
<Box p='xl' pt='sm' className='h-full'> <Box>{children}</Box>
{children}
</Box>
</ModalsProvider> </ModalsProvider>
</MantineProvider> </MantineProvider>
</RecoilRootWrapper> </RecoilRootWrapper>

View File

@@ -1,6 +1,11 @@
import TileGrid from '@/components/TileGrid';
import React from 'react'; import React from 'react';
// Home page for siren // Home page for siren
export default function Page() { export default function Page() {
return <div></div>; return (
<div>
<TileGrid />
</div>
);
} }

View File

@@ -3,6 +3,7 @@
justify-content: space-between; justify-content: space-between;
color: black; color: black;
border-bottom: 1px solid #e6e6e6; border-bottom: 1px solid #e6e6e6;
max-height: 70px;
} }
.navbar .left { .navbar .left {

View File

@@ -3,15 +3,16 @@
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import './header.css'; import './header.css';
import { Avatar, Button, Card, Grid, Group, Menu, Text, UnstyledButton } from '@mantine/core'; import { Avatar, Button, Card, FileButton, Grid, Group, Menu, Text, UnstyledButton } from '@mantine/core';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { logout, me, refreshLoggedIn } from '@/api/auth'; import { logout, refresh, refreshLoggedIn } from '@/api/auth';
import { useToggle } from '@mantine/hooks'; import { useToggle } from '@mantine/hooks';
import { HeaderModal } from './HeaderModal'; import { HeaderModal } from './HeaderModal';
import { HeaderItem, headerItems } from './headerItems'; import { HeaderItem, headerItems } from './headerItems';
import { userState } from '@/state/auth'; import { userState } from '@/state/auth';
import { useRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import { getPicture, setPicture } from '@/api/users';
export default function Header() { export default function Header() {
const pathName = usePathname(); const pathName = usePathname();
@@ -19,13 +20,19 @@ export default function Header() {
const [headers, setHeaders] = useState<HeaderItem[]>([]); const [headers, setHeaders] = useState<HeaderItem[]>([]);
const [user, setUser] = useRecoilState(userState); const [user, setUser] = useRecoilState(userState);
const [refreshId, setRefreshId] = useState<NodeJS.Timeout | undefined>(undefined); const [refreshId, setRefreshId] = useState<NodeJS.Timeout | undefined>(undefined);
const [profilePicture, setProfilePicture] = useState<File | null>(null);
useEffect(() => { useEffect(() => {
if (!user && Cookies.get('logged_in')) { if (!user || !Cookies.get('logged_in')) {
me().then((response) => { refresh().then((response) => {
if (response) { if (response) {
setRefreshId(refreshLoggedIn()); setRefreshId(refreshLoggedIn());
setUser(response.user); setUser(response.user);
getPicture().then((response) => {
if (response) {
setProfilePicture(response as File);
}
});
} }
}); });
} }
@@ -62,27 +69,51 @@ export default function Header() {
<Menu.Target> <Menu.Target>
<UnstyledButton className='user user-button'> <UnstyledButton className='user user-button'>
<Group> <Group>
<Avatar /> <Avatar src={profilePicture ? URL.createObjectURL(profilePicture) : undefined} />
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<Text size='sm' fw={500}> <Text size='sm' fw={500}>
{user.first_name} {user.last_name} {user.first_name} {user.last_name}
</Text> </Text>
<Text c='dimmed' size='xs' style={{ textTransform: 'uppercase' }}>
<Text c='dimmed' size='xs'>
{user.role} {user.role}
</Text> </Text>
</div> </div>
</Group> </Group>
</UnstyledButton> </UnstyledButton>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown p={0}>
<Card> <Card>
<Card.Section h={140} style={{}} /> <Card.Section h={140} style={{ backgroundColor: '#4481e3' }} />
<Avatar size={80} radius={80} mx={'auto'} mt={-30} /> <FileButton
onChange={(payload) => {
if (payload) {
setPicture(payload).then((response) => {
if (response) {
setProfilePicture(payload);
}
});
}
}}
accept='image/png,image/jpeg,image/jpg'
multiple={false}
>
{(props) => (
<Avatar
{...props}
component='button'
size={80}
radius={80}
mx={'auto'}
mt={-30}
style={{ cursor: 'pointer' }}
src={profilePicture ? URL.createObjectURL(profilePicture) : undefined}
/>
)}
</FileButton>
<Text ta='center' fz='lg' fw={500} mt='sm'> <Text ta='center' fz='lg' fw={500} mt='sm'>
{user.first_name} {user.last_name} {user.first_name} {user.last_name}
</Text> </Text>
<Text ta='center' fz='sm' c='dimmed'> <Text ta='center' fz='sm' c='dimmed' style={{ textTransform: 'uppercase' }}>
{user.role} {user.role}
</Text> </Text>
<Grid mt='xl'> <Grid mt='xl'>

View File

View File

@@ -0,0 +1,38 @@
'use client';
import { Graphics, Stage } from '@pixi/react';
import { Graphics as PixiGraphics } from '@pixi/graphics';
import { useCallback } from 'react';
// export default function TileGrid({ width, height }: TileGridProps) {
export default function TIleGrid() {
// Offset height of navbar from window height
const height = window.innerHeight - 75;
// Offset width of layout padding from window width
const width = window.innerWidth;
const draw = useCallback((g: PixiGraphics) => {
g.clear();
// Draw dot in the corner of each tile
for (let x = 0; x < width; x += 32) {
for (let y = 0; y < height; y += 32) {
g.beginFill(0xffffff, 0.5);
g.drawCircle(x, y, 1);
g.endFill();
}
}
}, []);
return (
<Stage
width={width}
height={height}
options={{
backgroundColor: 0x333333,
antialias: false
}}
>
<Graphics draw={draw} />
</Stage>
);
}

View File

@@ -0,0 +1,9 @@
.tile {
padding: 0;
margin: 0;
width: 100vw;
max-width: 100%;
height: 100vh;
max-height: 100%;
user-select: none;
}

View File

@@ -1,11 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ESNext", "target": "ES2022",
"downlevelIteration": true, "downlevelIteration": true,
"lib": [ "lib": [
"dom", "dom",
"dom.iterable", "dom.iterable",
"esnext" "ES2022"
], ],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
@@ -13,8 +13,8 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noEmit": true, "noEmit": true,
"esModuleInterop": true, "esModuleInterop": true,
"module": "esnext", "module": "ES2022",
"moduleResolution": "node", "moduleResolution": "Node",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "preserve",
@@ -30,7 +30,8 @@
"@api/*": ["src/api"], "@api/*": ["src/api"],
"@app/*": ["./src/app/*"], "@app/*": ["./src/app/*"],
"@components/*": ["src/components/*"], "@components/*": ["src/components/*"],
"@lib/*": ["src/components/*"] "@js/*": ["src/js/*"],
"@state/*": ["src/state/*"]
} }
}, },
"include": [ "include": [