From 294a1899b87187ef3278033cf420c6179560aa09 Mon Sep 17 00:00:00 2001 From: Moritz Bergmann Date: Fri, 29 Dec 2023 18:21:04 +0100 Subject: [PATCH] API Documentation (#28) --- .gitignore | 6 + docs/openapi.yaml | 471 ++++++++++++++++++++ public/auth/login.js | 12 +- public/auth/sign_up.html | 1 - public/auth/sign_up.js | 13 +- public/home.js | 108 +---- public/index.html | 3 +- public/index.js | 9 +- public/record.js | 20 +- public/scripts/activity.js | 50 --- public/scripts/api/account.js | 149 +++++++ public/scripts/api/activities.js | 143 ++++++ public/scripts/api/auth.js | 44 ++ public/scripts/api/main.js | 22 + public/scripts/api/users.js | 46 ++ public/scripts/auth.js | 81 ---- public/scripts/{variables.js => helpers.js} | 88 +++- public/scripts/requests.js | 32 -- public/scripts/user.js | 24 - public/style/login.css | 42 ++ public/style/record.css | 42 ++ public/style/sign_up.css | 42 ++ src/account.rs | 56 +++ src/database.rs | 294 ++++++++++++ src/logic.rs | 202 +-------- src/logic/account.rs | 126 ++++++ src/logic/activities.rs | 160 +++++++ src/logic/auth.rs | 35 ++ src/logic/users.rs | 40 ++ src/main.rs | 17 +- src/routes.rs | 80 ++++ src/services.rs | 62 --- src/storage.rs | 197 -------- src/user.rs | 46 -- 34 files changed, 1941 insertions(+), 822 deletions(-) create mode 100644 docs/openapi.yaml delete mode 100644 public/scripts/activity.js create mode 100644 public/scripts/api/account.js create mode 100644 public/scripts/api/activities.js create mode 100644 public/scripts/api/auth.js create mode 100644 public/scripts/api/main.js create mode 100644 public/scripts/api/users.js delete mode 100644 public/scripts/auth.js rename public/scripts/{variables.js => helpers.js} (79%) delete mode 100644 public/scripts/requests.js delete mode 100644 public/scripts/user.js create mode 100644 public/style/login.css create mode 100644 public/style/record.css create mode 100644 public/style/sign_up.css create mode 100644 src/account.rs create mode 100644 src/database.rs create mode 100644 src/logic/account.rs create mode 100644 src/logic/activities.rs create mode 100644 src/logic/auth.rs create mode 100644 src/logic/users.rs create mode 100644 src/routes.rs delete mode 100644 src/services.rs delete mode 100644 src/storage.rs delete mode 100644 src/user.rs diff --git a/.gitignore b/.gitignore index 8cc055f..76944bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,10 @@ +# rust /target + +# ide's .idea/ +.fleet/ + +# database data.db .DS_Store diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 0000000..a65bdf6 --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,471 @@ +openapi: 3.0.0 +info: + version: 0.1.0 + title: Sport Challenge + contact: + name: Moritz Bergmann + url: 'https://github.com/mobergmann/sport-challenge' + description: > + API for the Sport Challenge Backend. Can be used to build a frontend from + the App. + +servers: + - url: 'http://0.0.0.0:3000/v1' + description: Your own hosted Server + +tags: + - name: auth + description: Endpoints for login and logout. + - name: account + description: Endpoint for modifying your personal accound or creating a new account. + - name: users + description: Endpoint for accessing the public profile for other users on the platform. + - name: activities + description: Endpoint for accessing activities. + +paths: + /auth/login: + post: + description: Creates a session for the current user + operationId: login + tags: + - auth + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginAccount' + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/Account' + '403': + description: The password didn't match + content: + application/json: + schema: + type: object + properties: + message: + type: string + '404': + description: The username doesn't exist + content: + application/json: + schema: + type: object + properties: + message: + type: string + /auth/logout: + put: + description: Invalidates the users session and therefore logs the user out. + operationId: logout + tags: + - auth + responses: + '200': + description: Successful response + /account: + get: + description: Returns the Account object for the current Account/Session. + operationId: getAccount + tags: + - account + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/Account' + '401': + description: The current session is not valid + post: + description: For creating a new account/ Signing Up + operationId: sign_up + tags: + - account + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateAccount' + responses: + '201': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/Account' + '409': + description: The username or the E-Mail is already taken + content: + application/json: + schema: + type: object + properties: + message: + type: string + put: + description: >- + Edit the properties of the current Account (except the password, which + has to be set by a seperate route). + operationId: edit_account + tags: + - account + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EditAccount' + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/Account' + '401': + description: The current session is not valid + '409': + description: The username or the E-Mail is already taken + content: + application/json: + schema: + type: object + properties: + message: + type: string + delete: + description: (Permanently!!!) Delete the current Account. + operationId: delete_account + tags: + - account + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PasswordValidation' + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/Account' + '401': + description: The current session is not valid + /account/password: + put: + description: Edit the Password of the current Account. + operationId: edit_account_password + tags: + - account + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePassword' + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/Account' + '403': + description: User is not logged in/ Session is not valid + '/users/{username}': + get: + description: Returns the public profile of an User. + operationId: get_user + tags: + - users + parameters: + - name: username + description: Username of the User + in: path + required: true + schema: + type: string + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '404': + description: User with the username does not exist + '403': + description: User is not logged in/ Session is not valid + '/users/id/{id}': + get: + description: Returns the public profile of an User. + operationId: get_user_id + tags: + - users + parameters: + - name: id + description: ID of the User + in: path + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '404': + description: User with the id does not exist + '403': + description: User is not logged in/ Session is not valid + /activities: + post: + description: Push a new Activity. + operationId: create_activity + tags: + - activities + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewActivity' + responses: + '201': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/Activity' + '403': + description: User is not logged in/ Session is not valid + '/activities/{id}': + get: + description: Get an Activity. + operationId: get_activity + tags: + - activities + parameters: + - name: id + description: ID of the activity + in: path + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/Activity' + '404': + description: Activity with the id does not exist + '403': + description: User is not logged in/ Session is not valid + put: + description: Edit an Activity. + tags: + - activities + operationId: edit_activity + parameters: + - name: id + description: ID of the activity + in: path + required: true + schema: + type: integer + format: int64 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewActivity' + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/Activity' + '404': + description: Activity with the id does not exist + '403': + description: User is not logged in/ Session is not valid + delete: + description: Delete an Activity. + operationId: delete_activity + tags: + - activities + parameters: + - name: id + description: ID of the activity + in: path + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/Activity' + '404': + description: Activity with the id does not exist + '403': + description: User is not logged in/ Session is not valid + '/activities/{from}/{to}': + get: + description: Get a list of activities in an given time Interval. + operationId: get_activities_time_intervall + tags: + - activities + parameters: + - name: from + description: RFC 3339 Compliant DateTime String + in: path + required: true + schema: + type: string + format: date + - name: to + description: RFC 3339 Compliant DateTime String + in: path + required: true + schema: + type: string + format: date + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Activity' + '400': + description: Bad Request + content: + application/json: + schema: + type: object + properties: + message: + type: string + '403': + description: User is not logged in/ Session is not valid + +components: + schemas: + Account: + type: object + properties: + id: + type: integer + format: int64 + username: + type: string + password_hash: + type: string + User: + type: object + properties: + id: + type: integer + format: int64 + username: + type: string + LoginAccount: + type: object + properties: + username: + type: string + password: + type: string + format: password + CreateAccount: + type: object + properties: + username: + type: string + password: + type: string + format: password + EditAccount: + type: object + properties: + username: + type: string + ActivityType: + type: string + enum: + - PushUps + Activity: + type: object + properties: + id: + type: integer + format: int64 + author_id: + type: integer + format: int64 + amount: + type: integer + format: int64 + activity_type: + $ref: '#/components/schemas/ActivityType' + start_time: + type: string + format: date + end_time: + type: string + format: date + NewActivity: + type: object + properties: + amount: + type: integer + format: int64 + activity_type: + $ref: '#/components/schemas/ActivityType' + start_time: + type: string + format: date + end_time: + type: string + format: date + PasswordValidation: + type: object + properties: + current_password: + type: string + format: password + UpdatePassword: + type: object + properties: + current_password: + type: string + format: password + new_password: + type: string + format: password diff --git a/public/auth/login.js b/public/auth/login.js index 171adaa..bda7a45 100644 --- a/public/auth/login.js +++ b/public/auth/login.js @@ -1,4 +1,4 @@ -import {sign_in} from "/scripts/auth.js"; +import {login} from "/scripts/api/auth.js"; document.querySelector("#form").addEventListener("submit", async (e) => { e.preventDefault(); @@ -6,11 +6,11 @@ document.querySelector("#form").addEventListener("submit", async (e) => { const username = document.querySelector("#username").value; const password = document.querySelector("#password").value; - try { - const user = await sign_in(username, password); + const res = await login(username, password); + if (res.ok) { window.location = "/home.html"; - } catch (error) { - console.error(error); - alert("Login not successful. Please try again"); + } else { + console.error(res.value); + alert(`Login not successful: ${res.value}`); } }); diff --git a/public/auth/sign_up.html b/public/auth/sign_up.html index 436e867..f38cf95 100644 --- a/public/auth/sign_up.html +++ b/public/auth/sign_up.html @@ -57,7 +57,6 @@

Sign Up

- diff --git a/public/auth/sign_up.js b/public/auth/sign_up.js index 3ee46f1..1b82c34 100644 --- a/public/auth/sign_up.js +++ b/public/auth/sign_up.js @@ -1,4 +1,4 @@ -import {sign_up} from "/scripts/auth.js"; +import {NewAccount, create} from "/scripts/api/account.js"; document.querySelector("#form").addEventListener("submit", async (e) => { e.preventDefault(); @@ -12,11 +12,12 @@ document.querySelector("#form").addEventListener("submit", async (e) => { return; } - try { - const user = await sign_up(username, password1); + const account = new NewAccount(username, password1); + const res = await create(account); + if (res.ok) { window.location = "/auth/login.html"; - } catch (error) { - console.error(error); - alert("SignUp not successful. Please try again."); + } else { + console.error(res.value); + alert(`SignUp not successful: ${res.value}`); } }); diff --git a/public/home.js b/public/home.js index 2a0e456..cf079f6 100644 --- a/public/home.js +++ b/public/home.js @@ -1,95 +1,8 @@ import "/scripts/helper.js"; -import {get_activities} from "/scripts/activity.js"; -import {get_user_by_id} from "/scripts/user.js"; -import {ping} from "/scripts/requests.js"; - - -/// @source: https://stackoverflow.com/a/31810991/11186407 -Date.prototype.getWeek = function() { - let onejan = new Date(this.getFullYear(), 0, 1); - let today = new Date(this.getFullYear(), this.getMonth(), this.getDate()); - let dayOfYear = ((today - onejan + 86400000) / 86400000); - return Math.ceil(dayOfYear / 7) -}; - -/// @source: https://stackoverflow.com/a/5210450/11186407 -Date.prototype.getFirstWeekDay = function() { - // strip time away and set date and time to the beginning of the week-day - let curr = new Date(this.toDateString()); - curr.setHours(0); - curr.setMinutes(0); - curr.setSeconds(0); - curr.setMilliseconds(0); - let first = curr.getDate() - curr.getDay(); // First day is the day of the month - the day of the week - return new Date(curr.setDate(first)); -} - -/// @source: https://stackoverflow.com/a/5210450/11186407 -Date.prototype.getLastWeekDay = function() { - // strip time away and set date and time to the end of the week-day - let curr = new Date(this.toDateString()); - curr.setHours(23); - curr.setMinutes(59); - curr.setSeconds(59); - curr.setMilliseconds(999); - let first = curr.getDate() - curr.getDay(); // First day is the day of the month - the day of the week - let last = first + 6; // last day is the first day + 6 - return new Date(curr.setDate(last)); -} - -/// @source: https://stackoverflow.com/a/5210450/11186407 -Date.prototype.nextWeek = function() { - this.setDate(this.getDate() + 7); -} - -/// @source: https://stackoverflow.com/a/5210450/11186407 -Date.prototype.previousWeek = function() { - this.setDate(this.getDate() - 7); -} - -Date.prototype.dateToHumanReadable = function() { - return `${this.getDate()}.${this.getMonth() + 1}.${this.getFullYear()}`; -} - -Date.prototype.getTimezoneString = function() { - let tz_plus_minus = ""; - - let timezone_offset = this.getTimezoneOffset() * (-1); - if (timezone_offset >= 0) { - tz_plus_minus += "+"; - } else if (timezone_offset < 0) { - tz_plus_minus += "-"; - } - - let h = Math.trunc(timezone_offset / 60); - let m = timezone_offset % 60; - return `${tz_plus_minus}${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`; -} - -Date.prototype.toRFC3339 = function() { - let iso = this.toISOString(); - iso = iso.substring(0, iso.length - 1); - return `${iso}${this.getTimezoneString()}`; -} - -/// calculates the sums of the elements of the array -Array.prototype.sum = function() { - let sum = 0; - for (const element of this) { - sum += element; - } - return sum; -} - -/// calculates the sums of the elements of the array by using the specified key -Array.prototype.sum = function(key) { - let sum = 0; - for (const element of this) { - sum += element[key]; - } - return sum; -} - +import "/scripts/helpers.js" +import {get_from_to as get_activities} from "/scripts/api/activities.js"; +import {get_id as get_user_by_id} from "/scripts/api/users.js"; +import {get as get_account} from "/scripts/api/account.js"; // Global Variables let current_week = new Date(); @@ -101,7 +14,11 @@ let global_chart; async function prepare_activities_data(from, to) { let activities_per_user = new Map(); - for (const activity of await get_activities(from, to)) { + let res = await get_activities(from, to); + if (!res.ok) { + alert("Error while fetching the activities. Are you logged in?"); + } + for (const activity of res.value) { // if author not already in map, insert the author as a key and an empty list if (!activities_per_user.has(activity.author_id)) { activities_per_user.set(activity.author_id, []); @@ -309,11 +226,10 @@ async function update_frontend() { } async function main() { - try { - await ping(); - } catch (error) { + let res = await get_account(); + if (!res.ok) { alert("You are not signed in. Sign in first."); - window.location = "/auth/login.html" + window.location = "/auth/login.html"; } await update_frontend(); diff --git a/public/index.html b/public/index.html index e4d41ec..0404b45 100644 --- a/public/index.html +++ b/public/index.html @@ -25,7 +25,7 @@

Sport Challenge

-
+ diff --git a/public/index.js b/public/index.js index 2dd6246..4cb5054 100644 --- a/public/index.js +++ b/public/index.js @@ -1,7 +1,6 @@ -import {ping} from "/scripts/requests.js" +import {get} from "/scripts/api/account.js" -try { - await ping(); - document.querySelector("#already_loggedin").style.display = "block"; +let res = await get(); +if (res.ok) { + document.querySelector("#dashboard-link").style.display = "block"; } -catch (error) {} diff --git a/public/record.js b/public/record.js index efa5a10..5ec7f1e 100644 --- a/public/record.js +++ b/public/record.js @@ -1,5 +1,5 @@ -import {new_activity} from "/scripts/activity.js"; -import {TIMEZONE_INTS} from "/scripts/variables.js"; +import {create, NewActivity} from "/scripts/api/activities.js"; +import {TIMEZONE_INTS} from "/scripts/helpers.js"; function spawn_timzone(id, parent) { // @source: https://stackoverflow.com/a/52265733/11186407 @@ -18,6 +18,11 @@ function spawn_timzone(id, parent) { return select; } + let label = document.createElement("label"); + label.htmlFor = id; + label.innerHTML = "Timezone"; + parent.appendChild(label); + let select = timezone_dom_select(); select.id = id; select.classList.add("w100", "input"); @@ -62,11 +67,12 @@ document.querySelector("#form").addEventListener("submit", async (e) => { // todo add timezone to end of string console.log(end_time); - try { - let result = await new_activity(amount, activity_type, start_time, end_time); + let activity = new NewActivity(amount, activity_type, start_time, end_time); + let res = await create(activity); + if (res.ok) { window.location = "/home.html"; - } catch (error) { - console.error(error); - alert("Error while submitting new activity. Please try again."); + } else { + console.error(res.value); + alert(`Error while submitting new activity: ${res.value}`); } }); diff --git a/public/scripts/activity.js b/public/scripts/activity.js deleted file mode 100644 index 390d23d..0000000 --- a/public/scripts/activity.js +++ /dev/null @@ -1,50 +0,0 @@ -import {BASE_ACTIVITIES_URL} from "./variables.js"; -import {do_request} from "./requests.js" - -export async function get_activity(id) { - const request = new Request(`${BASE_ACTIVITIES_URL}/${id}`, { - method: "GET", - headers: new Headers({ - "Content-Type": "application/json", - }), - }); - - return await do_request(request); -} - -export async function get_activities(from, to) { - const request = new Request(`${BASE_ACTIVITIES_URL}/${from}/${to}`, { - method: "GET", - }); - - return await do_request(request); -} - -export async function new_activity(amount, activity_type, start_time, end_time) { - const activity = { - amount: amount, - activity_type: activity_type, - start_time: start_time, - end_time: end_time, - }; - - const request = new Request(`${BASE_ACTIVITIES_URL}`, { - method: "POST", - body: JSON.stringify(activity), - headers: new Headers({ - "Content-Type": "application/json", - }), - }); - return await do_request(request); -} - -export function edit_activity(amount, activity_type, start_time, end_time) { - //let amount = document.getElementById("edit_activity-amount").value; - //let activity_type = document.getElementById("edit_activity-activity_type").value; - //let start_time = document.getElementById("edit_activity-start_time").value; - //let end_time = document.getElementById("edit_activity-end_time").value; -} - -export function delete_activity(id) { - //let id = document.getElementById("delete_activity-id").value; -} diff --git a/public/scripts/api/account.js b/public/scripts/api/account.js new file mode 100644 index 0000000..d93a202 --- /dev/null +++ b/public/scripts/api/account.js @@ -0,0 +1,149 @@ +import {BASE_ACCOUNT_URL, STATUS, Result} from "./main.js" + +export class Account { + constructor(id, username, password_hash) { + this.id = id; + this.username = username; + this.password_hash = password_hash; + } +} + +export class NewAccount { + constructor(username, password) { + this.username = username; + this.password = password; + } +} + +export class EditAccount { + constructor(username) { + this.username = username; + } +} + +export class EditPassword { + constructor(current_password, new_password) { + this.current_password = current_password; + this.new_password = new_password; + } +} + +export class PasswordValidation { + constructor(current_password) { + this.current_password = current_password; + } +} + +/// returns the current account +/// :return `Account` +export async function get() { + const request = new Request(`${BASE_ACCOUNT_URL}`, { + method: "GET", + credentials: 'include', + }); + + let response = await fetch(request); + if (response.status === STATUS.OK) { + let value = await response.json(); + return new Result(true, new Account(value.id, value.username, value.password_hash)); + } else { + let error = await response.text(); + return new Result(false, error); + } +} + +/// create a new account +/// :param account `NewAccount` +/// :return `Account` newly created account +export async function create(account) { + const request = new Request(`${BASE_ACCOUNT_URL}`, { + method: "POST", + headers: new Headers({ + "Content-Type": "application/json", + }), + body: JSON.stringify(account), + credentials: 'include', + }); + + let response = await fetch(request); + if (response.status === STATUS.CREATED) { + let value = await response.json(); + return new Result(true, new Account(value.id, value.username, value.password_hash)); + } else { + let error = await response.text(); + return new Result(false, error); + } +} + +/// edit an account +/// :param account `EditAccount` +/// :return `Account` edited account +export async function edit(account) { + const request = new Request(`${BASE_ACCOUNT_URL}`, { + method: "PUT", + headers: new Headers({ + "Content-Type": "application/json", + }), + body: JSON.stringify(account), + credentials: 'include', + }); + + let response = await fetch(request); + if (response.status === STATUS.OK) { + let value = await response.json(); + return new Result(true, new Account(value.id, value.username, value.password_hash)); + } else { + let error = await response.text(); + return new Result(false, error); + } +} + +/// edit the password of an account +/// :param current_password the accounts current password +/// :param new_password the new password for the account +/// :return `Account` updated account +export async function edit_password(current_password, new_password) { + const request = new Request(`${BASE_ACCOUNT_URL}`, { + method: "PUT", + headers: new Headers({ + "Content-Type": "application/json", + }), + body: JSON.stringify({ + current_password: current_password, + new_password: new_password + }), + credentials: 'include', + }); + + let response = await fetch(request); + if (response.status === STATUS.OK) { + let value = await response.json(); + return new Result(true, new Account(value.id, value.username, value.password_hash)); + } else { + let error = await response.text(); + return new Result(false, error); + } +} + +/// deletes the account +/// :param current_password the accounts current password +/// :return `Account` deleted account +export async function remove(current_password) { + const request = new Request(`${BASE_ACCOUNT_URL}`, { + method: "DELETE", + headers: new Headers({ + "Content-Type": "application/json", + }), + body: JSON.stringify({current_password: current_password}), + credentials: 'include', + }); + + let response = await fetch(request); + if (response.status === STATUS.OK) { + let value = await response.json(); + return new Result(true, new Account(value.id, value.username, value.password_hash)); + } else { + let error = await response.text(); + return new Result(false, error); + } +} diff --git a/public/scripts/api/activities.js b/public/scripts/api/activities.js new file mode 100644 index 0000000..36d0cb5 --- /dev/null +++ b/public/scripts/api/activities.js @@ -0,0 +1,143 @@ +import {BASE_ACTIVITIES_URL, STATUS, Result} from "./main.js"; + +export const ActivitType = { + PushUps: "PushUps" +} + +export class Activity { + constructor(id, author_id, amount, activity_type, start_time, end_time) { + this.id = id; + this.author_id = author_id; + this.amount = amount; + this.activity_type = activity_type; + this.start_time = start_time; + this.end_time = end_time; + } +} + +export class NewActivity { + constructor(amount, activity_type, start_time, end_time) { + this.amount = amount; + this.activity_type = activity_type; + this.start_time = start_time; + this.end_time = end_time; + } +} + +export class EditActivity { + constructor(amount, activity_type, start_time, end_time) { + this.amount = amount; + this.activity_type = activity_type; + this.start_time = start_time; + this.end_time = end_time; + } +} + +/// get a list of activities in a given time interval +/// :param id the id of the activity +/// :return `Activity` returns a single activity +export async function get(id) { + const request = new Request(`${BASE_ACTIVITIES_URL}/${id}`, { + method: "GET", + credentials: 'include', + }); + + let response = await fetch(request); + if (response.status === STATUS.OK) { + let value = await response.json(); + return new Result(true, new Activity(value.id, value.author_id, value.amount, value.activity_type, value.start_time, value.end_time)); + } else { + let error = await response.text(); + return new Result(false, error); + } +} + +/// get a list of activities in a given time interval +/// :param from RFC-3339 compliant string. the timepoint at which the first activity should have started +/// :param to RFC-3339 compliant string. the last timepoint until which the last activity should have ended +/// :return `list[Activity]` returns a list of activites +export async function get_from_to(from, to) { + const request = new Request(`${BASE_ACTIVITIES_URL}/${from}/${to}`, { + method: "GET", + credentials: 'include', + }); + + let response = await fetch(request); + if (response.status === STATUS.OK) { + let raw = await response.json(); + let activities = []; + for (const value of raw) { + activities.push(new Activity(value.id, value.author_id, value.amount, value.activity_type, value.start_time, value.end_time)); + } + return new Result(true, activities); + } else { + let error = await response.text(); + return new Result(false, error); + } +} + +/// creates a new activity +/// :param activity the activity object of type `NewActivity` with the updated informations of the activity +/// :return `Activity` returns the newly created activity +export async function create(activity) { + const request = new Request(`${BASE_ACTIVITIES_URL}`, { + method: "POST", + headers: new Headers({ + "Content-Type": "application/json", + }), + body: JSON.stringify(activity), + credentials: 'include', + }); + + let response = await fetch(request); + if (response.status === STATUS.CREATED) { + let value = await response.json(); + return new Result(true, new Activity(value.id, value.author_id, value.amount, value.activity_type, value.start_time, value.end_time)); + } else { + let error = await response.text(); + return new Result(false, error); + } +} + +/// edits the information of an activity +/// :param id id if the activity to edit +/// :param activity the activity object of type `EditActivity` with the updated informations of the activvity +/// :return `Activity` returns the updated activity +export async function edit(id, activity) { + const request = new Request(`${BASE_ACTIVITIES_URL}/${id}`, { + method: "PUT", + headers: new Headers({ + "Content-Type": "application/json", + }), + body: JSON.stringify(activity), + credentials: 'include', + }); + + let response = await fetch(request); + if (response.status === STATUS.OK) { + let value = await response.json(); + return new Result(true, new Activity(value.id, value.author_id, value.amount, value.activity_type, value.start_time, value.end_time)); + } else { + let error = await response.text(); + return new Result(false, error); + } +} + +/// delete an activity +/// :param id id of the activity to remove +/// :return `Activity` returns the deleted activity +export async function remove(id) { + const request = new Request(`${BASE_ACTIVITIES_URL}/${id}`, { + method: "DELETE", + credentials: 'include', + }); + + let response = await fetch(request); + if (response.status === STATUS.OK) { + let value = await response.json(); + return new Result(true, new Activity(value.id, value.author_id, value.amount, value.activity_type, value.start_time, value.end_time)); + } else { + let error = await response.text(); + return new Result(false, error); + } +} diff --git a/public/scripts/api/auth.js b/public/scripts/api/auth.js new file mode 100644 index 0000000..4d45abe --- /dev/null +++ b/public/scripts/api/auth.js @@ -0,0 +1,44 @@ +import {BASE_AUTH_URL, STATUS, Result} from "./main.js"; +import {Account} from "./account.js"; + +/// login, creation a session and saving the session by setting a cookie +/// :param username username of the user +/// :param password password of the account +/// :return `Account` the account object of the now logged in user +export async function login(username, password) { + const request = new Request(`${BASE_AUTH_URL}/login`, { + method: "POST", + headers: new Headers({ + "Content-Type": "application/json", + }), + body: JSON.stringify({username: username, password: password}), + credentials: 'include', + }); + + let response = await fetch(request); + if (response.status === STATUS.OK) { + let value = await response.json(); + return new Result(true, new Account(value.id, value.username, value.password_hash)); + } else { + let error = await response.text(); + return new Result(false, error); + } +} + +/// log the current session out +export async function logout() { + const request = new Request(`${BASE_AUTH_URL}/logout`, { + method: "PUT", + credentials: 'include', + }); + + let response = await fetch(request); + if (response.status === STATUS.OK) { + let value = await response.json(); + return new Result(true, undefined); +// return new Result(true, new Account(value.id, value.username, value.password_hash)); + } else { + let error = await response.text(); + return new Result(new Result(false, error)); + } +} diff --git a/public/scripts/api/main.js b/public/scripts/api/main.js new file mode 100644 index 0000000..fed5b6c --- /dev/null +++ b/public/scripts/api/main.js @@ -0,0 +1,22 @@ +export const BASE_URL = "/v1"; +export const BASE_ACCOUNT_URL = `${BASE_URL}/account`; +export const BASE_USERS_URL = `${BASE_URL}/users`; +export const BASE_AUTH_URL = `${BASE_URL}/auth`; +export const BASE_ACTIVITIES_URL = `${BASE_URL}/activities`; + +export const STATUS = { + OK: 200, + CREATED: 201, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + CONFLICT: 409, + INTERNAL_SERVER_ERROR: 500, +}; + +export class Result { + constructor(ok, value) { + this.ok = ok; + this.value = value; + } +} diff --git a/public/scripts/api/users.js b/public/scripts/api/users.js new file mode 100644 index 0000000..424ce3c --- /dev/null +++ b/public/scripts/api/users.js @@ -0,0 +1,46 @@ +import {BASE_USERS_URL, STATUS, Result} from "./main.js"; + +export class User { + constructor(id, username) { + this.id = id; + this.username = username; + } +} + +/// get the public profile of a user +/// :param username the username of the user +/// :return `User` +export async function get(username) { + const request = new Request(`${BASE_USERS_URL}/${username}`, { + method: "GET", + credentials: 'include', + }); + + let response = await fetch(request); + if (response.status === STATUS.OK) { + let value = await response.json(); + return new Result(true, new User(value.id, value.username)); + } else { + let error = await response.text(); + return new Result(false, error); + } +} + +/// get the public profile of a user +/// :param id id of the user +/// :return `User` +export async function get_id(id) { + const request = new Request(`${BASE_USERS_URL}/id/${id}`, { + method: "GET", + credentials: 'include', + }); + + let response = await fetch(request); + if (response.status === STATUS.OK) { + let value = await response.json(); + return new Result(true, new User(value.id, value.username)); + } else { + let error = await response.text(); + return new Result(false, error); + } +} diff --git a/public/scripts/auth.js b/public/scripts/auth.js deleted file mode 100644 index 4d48453..0000000 --- a/public/scripts/auth.js +++ /dev/null @@ -1,81 +0,0 @@ -import {BASE_AUTH_URL} from "./variables.js"; -import {do_request} from "./requests.js"; - -/// sign up a user -export async function sign_up(username, password) { - const user = { - name: username, - password: password, - }; - - const request = new Request(`${BASE_AUTH_URL}/sign_up`, { - method: "POST", - body: JSON.stringify(user), - headers: new Headers({ - "Content-Type": "application/json", - }), - }); - - return await fetch(request) - .then((response) => { - if (response.status === 201) { - return response.json(); - } else { - throw new Error("Something went wrong on API server!"); - } - }) - .then((response) => { - console.debug("Sucessfull SignUp"); - console.debug(response); - return response; - }) - .catch((error) => { - console.error(error); - throw error; - }); -} - -/// sign in a user -export async function sign_in(username, password) { - const user = { - name: username, - password: password, - }; - - const request = new Request(`${BASE_AUTH_URL}/sign_in`, { - method: "POST", - headers: new Headers({ - "Content-Type": "application/json", - }), - body: JSON.stringify(user), - credentials: 'include', - }); - - return await do_request(request); -} - -/// sign out a user -export async function sign_out() { - const request = new Request(`${BASE_AUTH_URL}/sign_out`, { - method: "GET", - credentials: 'include', - }); - - return await fetch(request) - .then((response) => { - if (response.status === 200) { - return response; - } else { - throw new Error("Something went wrong on API server!"); - } - }) - .then((response) => { - console.debug("Sucessfull SignOut"); - console.debug(response); - return response; - }) - .catch((error) => { - console.error(error); - throw error; - }); -} diff --git a/public/scripts/variables.js b/public/scripts/helpers.js similarity index 79% rename from public/scripts/variables.js rename to public/scripts/helpers.js index da5a722..8670b26 100644 --- a/public/scripts/variables.js +++ b/public/scripts/helpers.js @@ -1,8 +1,88 @@ -export const BASE_URL = "/v1"; -export const BASE_AUTH_URL = `${BASE_URL}/auth`; -export const BASE_ACTIVITIES_URL = `${BASE_URL}/activities`; +/// @source: https://stackoverflow.com/a/31810991/11186407 +Date.prototype.getWeek = function() { + let onejan = new Date(this.getFullYear(), 0, 1); + let today = new Date(this.getFullYear(), this.getMonth(), this.getDate()); + let dayOfYear = ((today - onejan + 86400000) / 86400000); + return Math.ceil(dayOfYear / 7) +}; -export const BASE_USER_URL = `${BASE_URL}/user`; +/// @source: https://stackoverflow.com/a/5210450/11186407 +Date.prototype.getFirstWeekDay = function() { + // strip time away and set date and time to the beginning of the week-day + let curr = new Date(this.toDateString()); + curr.setHours(0); + curr.setMinutes(0); + curr.setSeconds(0); + curr.setMilliseconds(0); + let first = curr.getDate() - curr.getDay(); // First day is the day of the month - the day of the week + return new Date(curr.setDate(first)); +} + +/// @source: https://stackoverflow.com/a/5210450/11186407 +Date.prototype.getLastWeekDay = function() { + // strip time away and set date and time to the end of the week-day + let curr = new Date(this.toDateString()); + curr.setHours(23); + curr.setMinutes(59); + curr.setSeconds(59); + curr.setMilliseconds(999); + let first = curr.getDate() - curr.getDay(); // First day is the day of the month - the day of the week + let last = first + 6; // last day is the first day + 6 + return new Date(curr.setDate(last)); +} + +/// @source: https://stackoverflow.com/a/5210450/11186407 +Date.prototype.nextWeek = function() { + this.setDate(this.getDate() + 7); +} + +/// @source: https://stackoverflow.com/a/5210450/11186407 +Date.prototype.previousWeek = function() { + this.setDate(this.getDate() - 7); +} + +Date.prototype.dateToHumanReadable = function() { + return `${this.getDate()}.${this.getMonth() + 1}.${this.getFullYear()}`; +} + +Date.prototype.getTimezoneString = function() { + let tz_plus_minus = ""; + + let timezone_offset = this.getTimezoneOffset() * (-1); + if (timezone_offset >= 0) { + tz_plus_minus += "+"; + } else if (timezone_offset < 0) { + tz_plus_minus += "-"; + } + + let h = Math.trunc(timezone_offset / 60); + let m = timezone_offset % 60; + return `${tz_plus_minus}${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`; +} + +Date.prototype.toRFC3339 = function() { + let iso = this.toISOString(); + iso = iso.substring(0, iso.length - 1); + return `${iso}${this.getTimezoneString()}`; +} + +/// calculates the sums of the elements of the array +Array.prototype.sum = function() { + let sum = 0; + for (const element of this) { + sum += element; + } + return sum; +} + +/// calculates the sums of the elements of the array by using the specified key +Array.prototype.sum = function(key) { + let sum = 0; + for (const element of this) { + sum += element[key]; + } + return sum; +} // @source: https://stackoverflow.com/a/52265733/11186407 export const TIMEZONE_NAMES = [ diff --git a/public/scripts/requests.js b/public/scripts/requests.js deleted file mode 100644 index b71f13d..0000000 --- a/public/scripts/requests.js +++ /dev/null @@ -1,32 +0,0 @@ -import {BASE_URL} from "./variables.js"; - -export async function do_request(request, body_expected = true) { - return await fetch(request) - .then((response) => { - if (response.status === 200) { - if (!body_expected) { - return response; - } else { - return response.json(); - } - } else { - throw new Error("Something went wrong on API server!"); - } - }) - .then((response) => { - return response; - }) - .catch((error) => { - console.error(error); - throw error; - }); -} - - -export async function ping() { - const request = new Request(`${BASE_URL}/ping`, { - method: "GET", - }); - - return await do_request(request, false); -} diff --git a/public/scripts/user.js b/public/scripts/user.js deleted file mode 100644 index 959062e..0000000 --- a/public/scripts/user.js +++ /dev/null @@ -1,24 +0,0 @@ -import {BASE_USER_URL} from "./variables.js"; -import {do_request} from "./requests.js" - -export async function get_user(username) { - const request = new Request(`${BASE_USER_URL}/${username}`, { - method: "GET", - headers: new Headers({ - "Content-Type": "application/json", - }), - }); - - return await do_request(request); -} - -export async function get_user_by_id(id) { - const request = new Request(`${BASE_USER_URL}/id/${id}`, { - method: "GET", - headers: new Headers({ - "Content-Type": "application/json", - }), - }); - - return await do_request(request); -} diff --git a/public/style/login.css b/public/style/login.css new file mode 100644 index 0000000..fc7c11f --- /dev/null +++ b/public/style/login.css @@ -0,0 +1,42 @@ +body { + /* be responsive */ + max-width: 30rem; + + /* center vertically and horizontally */ + margin: auto; + height: 100vh; + display: flex; + justify-content: center; + align-items: center; +} + +form { + width: 100%; +} + +fieldset { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + padding-left: 2rem; + padding-right: 2rem; + + color: black; + background-color: white; + border: 1px solid #666; + border-radius: 7px; +} + +legend { + font-weight: bold; +} + +.form-buttons-container { + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; + margin: 1rem; +} diff --git a/public/style/record.css b/public/style/record.css new file mode 100644 index 0000000..1873f74 --- /dev/null +++ b/public/style/record.css @@ -0,0 +1,42 @@ +body { + /* be responsive */ + max-width: 30rem; + + /* center vertically and horizontally */ + margin: auto; + height: 100vh; + display: flex; + justify-content: center; + align-items: center; +} + +form { + width: 100%; +} + +fieldset { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + padding-left: 2rem; + padding-right: 2rem; + + color: black; + background-color: white; + border: 1px solid #666; + border-radius: 7px; +} + +legend { + font-weight: bold; +} + +.form-buttons-container { + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; + margin: 1rem; +} diff --git a/public/style/sign_up.css b/public/style/sign_up.css new file mode 100644 index 0000000..1873f74 --- /dev/null +++ b/public/style/sign_up.css @@ -0,0 +1,42 @@ +body { + /* be responsive */ + max-width: 30rem; + + /* center vertically and horizontally */ + margin: auto; + height: 100vh; + display: flex; + justify-content: center; + align-items: center; +} + +form { + width: 100%; +} + +fieldset { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + padding-left: 2rem; + padding-right: 2rem; + + color: black; + background-color: white; + border: 1px solid #666; + border-radius: 7px; +} + +legend { + font-weight: bold; +} + +.form-buttons-container { + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; + margin: 1rem; +} diff --git a/src/account.rs b/src/account.rs new file mode 100644 index 0000000..d949219 --- /dev/null +++ b/src/account.rs @@ -0,0 +1,56 @@ +use axum_login::secrecy::SecretVec; +use axum_login::AuthUser; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use std::convert::From; +use std::convert::Into; + +/// Object storing all Account related data +#[derive(Clone, Debug, Default, Deserialize, Serialize, FromRow)] +pub struct Account { + pub id: i64, + pub username: String, + pub password_hash: String, +} + +impl AuthUser for Account { + fn get_id(&self) -> i64 { + self.id + } + + fn get_password_hash(&self) -> SecretVec { + SecretVec::new(self.password_hash.clone().into()) + } +} + +/// A subset of the `Account` object, but only contains the properties which can be edited directly. +#[derive(Deserialize)] +pub struct EditAccount { + pub username: String, +} + +/// Like `Account`, but instead of storing the password hash, it stores the plain-text password. +/// Is used when the user logs-in or signs-up. !!Should not be used outside of these routes!! +#[derive(Deserialize)] +pub struct BareAccount { + pub username: String, + pub password: String, +} + +/// `User` is like `Account` but does not contain sensible information, +/// like the accounts password-hash or the E-Mail +#[derive(Serialize)] +pub struct User { + pub id: i64, + pub username: String, +} + +/// Because a `User` is a subset of the `Account` we can convert any `Account` into a `User`. +impl From for User { + fn from(user: Account) -> Self { + Self { + id: user.id, + username: user.username, + } + } +} diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 0000000..77c498b --- /dev/null +++ b/src/database.rs @@ -0,0 +1,294 @@ +use sqlx::sqlite::SqliteConnectOptions; +use sqlx::{Executor, SqlitePool}; +use std::str::FromStr; + +/// Path to the SQLite database +const DB_URI: &str = "sqlite://data.db"; + +/// Error enum also wrapping sqlx errors +pub enum Error { + SQLX(sqlx::Error), + NotImplemented, + Conflict(String), +} + +/// For when an `sqlx::Error` is thrown we can convert it implicitly into an `database::Error` +impl From for Error { + fn from(error: sqlx::Error) -> Self { + Error::SQLX(error) + } +} + +/// Initialize the database. +/// If the SQLite-File does not exist create it and create the tables. +pub async fn init() -> Result { + let pool_options = SqliteConnectOptions::from_str(DB_URI)? + //.filename(DB_URI) + .create_if_missing(true); + + let pool = SqlitePool::connect_with(pool_options).await?; + + let mut connection = pool.acquire().await?; + + // create users table + connection + .execute( + "CREATE TABLE IF NOT EXISTS 'users' ( + 'id' INTEGER UNIQUE, + 'username' TEXT NOT NULL UNIQUE, + 'password_hash' TEXT NOT NULL UNIQUE, + PRIMARY KEY('id' AUTOINCREMENT))", + ) + .await?; + + // create activities table + connection + .execute( + "CREATE TABLE IF NOT EXISTS 'activities' ( + 'id' INTEGER UNIQUE, + 'start_time' TEXT NOT NULL, + 'end_time' TEXT NOT NULL, + 'amount' INTEGER NOT NULL, + 'activity_type' TEXT NOT NULL, + 'author_id' INTEGER NOT NULL, + FOREIGN KEY('author_id') REFERENCES 'users'('id') ON DELETE CASCADE, + PRIMARY KEY('id' AUTOINCREMENT))", + ) + .await?; + + Ok(pool) +} + +pub mod account { + use crate::account::{Account, BareAccount, EditAccount}; + use crate::database::Error; + use crate::hasher; + use sqlx::SqlitePool; + + /// Returns an account by id + pub async fn get_id(pool: SqlitePool, id: i64) -> Result { + let mut connection = pool.acquire().await?; + + let user: Account = sqlx::query_as("SELECT * FROM users WHERE id = $1") + .bind(id) + .fetch_one(&mut connection) + .await?; + + Ok(user) + } + + /// Returns an account by name + pub async fn get(pool: SqlitePool, username: &String) -> Result { + let mut connection = pool.acquire().await?; + + let user: Account = sqlx::query_as("SELECT * FROM users WHERE username = $1") + .bind(username) + .fetch_one(&mut connection) + .await?; + + Ok(user) + } + + /// Inserts an Account and returns the inserted account + pub async fn insert(pool: SqlitePool, account: &BareAccount) -> Result { + let mut connection = pool.acquire().await?; + + let password_hash = hasher::hash(&account.password); + + let user: Account = + sqlx::query_as("INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING *") + .bind(&account.username) + .bind(password_hash) + .fetch_one(&mut connection) + .await?; + + Ok(user) + } + + /// Updates an account and returns the updated account + pub async fn update( + pool: SqlitePool, + id: i64, + account: &EditAccount, + ) -> Result { + let mut connection = pool.acquire().await?; + + let result: Account = sqlx::query_as("UPDATE users SET username = $1 WHERE id = $2 RETURNING *") + .bind(&account.username) + .bind(id) + .fetch_one(&mut connection) + .await?; + + Ok(result) + } + + /// Updates the password of the current account and returns the updated account + pub async fn update_password( + pool: SqlitePool, + id: i64, + new_password: String, + ) -> Result { + let mut connection = pool.acquire().await?; + + let password_hash = hasher::hash(&new_password); + + let user: Account = + sqlx::query_as("UPDATE users SET password_hash = $1 WHERE id = $2 RETURNING *") + .bind(password_hash) + .bind(id) + .fetch_one(&mut connection) + .await?; + + Ok(user) + } + + /// Deletes an Account and returns the deleted account + pub async fn delete(pool: SqlitePool, id: i64) -> Result { + let mut connection = pool.acquire().await?; + + let user: Account = + sqlx::query_as("DELETE FROM users WHERE id = $1 RETURNING *") + .bind(id) + .fetch_one(&mut connection) + .await?; + + Ok(user) + } +} + +pub mod user { + use crate::account::{Account, User}; + use crate::database::Error; + use sqlx::SqlitePool; + + /// Returns a user by username + pub async fn get(pool: SqlitePool, username: &String) -> Result { + let mut connection = pool.acquire().await?; + + let user: Account = sqlx::query_as("SELECT * FROM users WHERE username = $1") + .bind(username.as_str()) + .fetch_one(&mut connection) + .await?; + + Ok(From::from(user)) + } + + /// Returns a user by id + pub async fn get_id(pool: SqlitePool, id: i64) -> Result { + let mut connection = pool.acquire().await?; + + let user: Account = sqlx::query_as("SELECT * FROM users WHERE id = $1") + .bind(id) + .fetch_one(&mut connection) + .await?; + + Ok(From::from(user)) + } + + /// Checks weather a account/user (given by its username) exists + pub async fn exists(pool: SqlitePool, username: &String) -> bool { + let user = get(pool, username).await; + match user { + Ok(_) => true, + Err(_) => false, + } + } + + /// Checks weather a account/user (given by its is) exists + pub async fn exists_id(pool: SqlitePool, id: i64) -> bool { + let user = get_id(pool, id).await; + match user { + Ok(_) => true, + Err(_) => false, + } + } +} + +pub mod activity { + use crate::activity::{Activity, BareActivity}; + use crate::database::Error; + use chrono::{DateTime, Utc}; + use sqlx::SqlitePool; + + /// Returns an activity + pub async fn get(pool: SqlitePool, id: i64) -> Result { + let mut connection = pool.acquire().await?; + + let activity: Activity = sqlx::query_as("SELECT * FROM activities WHERE id = $1") + .bind(id) + .fetch_one(&mut connection) + .await?; + + Ok(activity) + } + + /// Returns a list of Activities which took place in the time interval from :from to :to + pub async fn get_interval( + pool: SqlitePool, + from: &DateTime, + to: &DateTime, + ) -> Result, Error> { + let mut connection = pool.acquire().await?; + + let activities: Vec = + sqlx::query_as("SELECT * FROM activities WHERE start_time >= $1 AND end_time <= $2") + .bind(from) + .bind(to) + .fetch_all(&mut connection) + .await?; + + Ok(activities) + } + + /// Inserts an activity into the database and returns the newly inserted activity + pub async fn insert( + pool: SqlitePool, + author_id: i64, + activity: &BareActivity, + ) -> Result { + let mut connection = pool.acquire().await?; + + let activity: Activity = sqlx::query_as("INSERT INTO activities (author_id, amount, activity_type, start_time, end_time) VALUES ($1, $2, $3, $4, $5) RETURNING *") + .bind(author_id) + .bind(activity.amount) + .bind(&activity.activity_type) + .bind(activity.start_time) + .bind(activity.end_time) + .fetch_one(&mut connection) + .await?; + + Ok(activity) + } + + /// Updates an activity and returns the updated activity + pub async fn update( + pool: SqlitePool, + id: i64, + activity: &BareActivity, + ) -> Result { + let mut connection = pool.acquire().await?; + + let result: Activity = sqlx::query_as("UPDATE activities SET amount = $1, activity_type = $2, start_time = $3, end_time = $4 WHERE id = $5 RETURNING *") + .bind(activity.amount) + .bind(&activity.activity_type) + .bind(activity.start_time) + .bind(activity.end_time) + .bind(id) + .fetch_one(&mut connection) + .await?; + + Ok(result) + } + + /// Deletes an activity and returns the deleted activity + pub async fn delete(pool: SqlitePool, id: i64) -> Result { + let mut connection = pool.acquire().await?; + + let activity: Activity = sqlx::query_as("DELETE FROM activities WHERE id = $1 RETURNING *") + .bind(id) + .fetch_one(&mut connection) + .await?; + + Ok(activity) + } +} diff --git a/src/logic.rs b/src/logic.rs index 38ce2b7..2211283 100644 --- a/src/logic.rs +++ b/src/logic.rs @@ -1,197 +1,9 @@ -use crate::activity::{BareActivity, StringBareActivity}; -use crate::storage::Error; -use crate::user::{BareUser, PublicUser, User}; -use crate::{hasher, storage}; -use axum::extract::Path; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::Json; -use axum_login::SqliteStore; -use chrono::{DateTime, Utc}; - -type AuthContext = axum_login::extractors::AuthContext>; - -pub async fn ping() -> impl IntoResponse { - (StatusCode::OK).into_response() -} - -pub async fn sign_up(Json(payload): Json) -> impl IntoResponse { - // if username already exists, return with error - if storage::user_exists(&payload.name).await { - return (StatusCode::CONFLICT).into_response(); - } - - // create a new user - match storage::insert_new_user(&payload).await { - Ok(user) => (StatusCode::CREATED, Json(user)).into_response(), - Err(_) => (StatusCode::INTERNAL_SERVER_ERROR).into_response(), - } -} - -pub async fn sign_in(mut auth: AuthContext, Json(payload): Json) -> impl IntoResponse { - let user = match storage::get_user(&payload.name).await { - Ok(user) => user, - Err(_) => return (StatusCode::NOT_FOUND, "name does not exist").into_response(), - }; - - if !hasher::verify(&user.password_hash, &payload.password) { - return (StatusCode::UNAUTHORIZED, "password doesn't match").into_response(); - } - - auth.login(&user).await.unwrap(); - (StatusCode::OK, Json(user)).into_response() -} - -pub async fn sign_out(mut auth: AuthContext) -> impl IntoResponse { - auth.logout().await; - (StatusCode::OK).into_response() -} - -pub async fn get_activity(mut auth: AuthContext, Path(activity_id): Path) -> impl IntoResponse { - let activity = match storage::get_activity(activity_id).await { - Ok(activity) => activity, - Err(Error::ElementNotFound) => return (StatusCode::NOT_FOUND).into_response(), - Err(_) => { - return (StatusCode::INTERNAL_SERVER_ERROR).into_response() - } - }; - (StatusCode::OK, Json(activity)).into_response() -} - -pub async fn get_activities_from_to(mut auth: AuthContext, Path((from, to)): Path<(String, String)>) -> impl IntoResponse { - let from = match DateTime::parse_from_rfc3339(&from) { - Ok(time) => time.with_timezone(&Utc), - Err(_) => { - return (StatusCode::BAD_REQUEST, Json(":from url-parameter is not a valid rfc3339 format")).into_response(); - } - }; - - let to = match DateTime::parse_from_rfc3339(&to) { - Ok(time) => time.with_timezone(&Utc), - Err(_) => { - return (StatusCode::BAD_REQUEST, Json(":to url-parameter is not a valid rfc3339 format")).into_response(); - } - }; - - if to < from { - return (StatusCode::BAD_REQUEST, Json("the :to time must be later than the :from time")).into_response(); - } - - println!("from: {}, to: {}", from, to); - - match storage::get_activities(&from, &to).await { - Ok(activities) => (StatusCode::OK, Json(activities)).into_response(), - Err(_) => (StatusCode::INTERNAL_SERVER_ERROR).into_response(), - } -} - -/* -pub async fn get_activities(mut auth: AuthContext) -> impl IntoResponse { - let activities = match storage::get_all_activities().await { - Ok(activities) => activities, - Err(_) => { - return (StatusCode::INTERNAL_SERVER_ERROR).into_response() - } - }; - (StatusCode::OK, Json(activities)).into_response() -} -*/ +pub mod account; +pub mod activities; +pub mod auth; +pub mod users; -pub async fn new_activity( - mut auth: AuthContext, - Json(payload): Json, -) -> impl IntoResponse { - let start_time = match DateTime::parse_from_rfc3339(&payload.start_time) { - Ok(time) => time.with_timezone(&Utc), - Err(_) => { - return (StatusCode::BAD_REQUEST, Json("start_time is not a valid rfc3339 format")).into_response(); - } - }; - - let end_time = match DateTime::parse_from_rfc3339(&payload.end_time) { - Ok(time) => time.with_timezone(&Utc), - Err(_) => { - return (StatusCode::BAD_REQUEST, Json("end_time is not a valid rfc3339 format")).into_response(); - } - }; - - if end_time < start_time { - return (StatusCode::BAD_REQUEST, Json("the end_time time must be later than the start_time")).into_response(); - } - - println!("from: {}, to: {}", end_time, start_time); - - match storage::new_activity(&BareActivity { - amount: payload.amount, - activity_type: payload.activity_type, - start_time: start_time, - end_time: end_time, - }, &auth.current_user.unwrap()).await - { - Ok(activity) => (StatusCode::OK, Json(activity)).into_response(), - Err(_) => { - (StatusCode::INTERNAL_SERVER_ERROR).into_response() - } - } -} - -pub async fn edit_activity( - mut auth: AuthContext, - Json(payload): Json, -) -> impl IntoResponse { - (StatusCode::NOT_IMPLEMENTED).into_response() -} - -pub async fn delete_activity( - mut auth: AuthContext, - Path(activity_id): Path, -) -> impl IntoResponse { - let activity = match storage::get_activity(activity_id).await { - Ok(activity) => activity, - Err(Error::ElementNotFound) => return (StatusCode::NOT_FOUND).into_response(), - Err(_) => { - return (StatusCode::INTERNAL_SERVER_ERROR).into_response() - } - }; - - // only the activity author is allowed to delete its activities - if activity.author_id != auth.current_user.unwrap().id { - return (StatusCode::UNAUTHORIZED).into_response(); - } - - let activity = match storage::delete_activity(activity_id).await { - Ok(activity) => activity, - Err(_) => { - return (StatusCode::INTERNAL_SERVER_ERROR).into_response() - } - }; - (StatusCode::OK, Json(activity)).into_response() -} - -pub async fn get_user(mut auth: AuthContext, Path(username): Path) -> impl IntoResponse -{ - let user = match storage::get_user(&username).await { - Ok(user) => user, - Err(Error::ElementNotFound) => return (StatusCode::NO_CONTENT).into_response(), - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR).into_response(), - }; - - (StatusCode::OK, Json(PublicUser::from(user))).into_response() -} - -pub async fn get_user_by_id(mut auth: AuthContext, Path(user_id): Path) -> impl IntoResponse -{ - let user = match storage::get_user_by_id(&user_id).await { - Ok(user) => user, - Err(Error::ElementNotFound) => return (StatusCode::NO_CONTENT).into_response(), - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR).into_response(), - }; - - (StatusCode::OK, Json(PublicUser::from(user))).into_response() -} - -pub async fn get_account() {} - -pub async fn edit_account() {} +use crate::account::Account; +use axum_login::SqliteStore; -pub async fn delete_account() {} +type AuthContext = axum_login::extractors::AuthContext>; diff --git a/src/logic/account.rs b/src/logic/account.rs new file mode 100644 index 0000000..25744f6 --- /dev/null +++ b/src/logic/account.rs @@ -0,0 +1,126 @@ +use crate::account::{BareAccount, EditAccount}; +use crate::{database, hasher}; +use crate::logic::AuthContext; +use crate::logic::auth::logout; + +use axum::extract::State; +use axum::response::IntoResponse; +use axum::Json; +use http::StatusCode; +use serde::Deserialize; +use sqlx::SqlitePool; + +#[derive(Deserialize)] +pub struct PasswordValidation { + pub current_password: String, +} + +#[derive(Deserialize)] +pub struct EditPassword { + pub current_password: String, + pub new_password: String, +} + +/// Returns the current logged in account object +pub async fn get_account(auth: AuthContext) -> impl IntoResponse { + (StatusCode::OK, Json(auth.current_user.unwrap())).into_response() +} + +/// Creates a new account and returns the just created account object +pub async fn post_account( + State(pool): State, + Json(payload): Json, +) -> impl IntoResponse { + // if username already exists, return with error + if database::user::exists(pool.clone(), &payload.username).await { + return (StatusCode::CONFLICT).into_response(); + } + + // create a new user + let user = match database::account::insert(pool, &payload).await { + Ok(user) => user, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR).into_response(), + }; + + (StatusCode::CREATED, Json(user)).into_response() +} + +/// Edit the current logged in account +pub async fn edit_account( + State(pool): State, + auth: AuthContext, + Json(payload): Json, +) -> impl IntoResponse { + // if username is different from the current user username and the username already exists, return an error + if payload.username != auth.current_user.clone().unwrap().username && database::user::exists(pool.clone(), &payload.username).await { + return (StatusCode::CONFLICT, "username already exists").into_response(); + } + + // edit the accounts information's in the database + let updated_user = + match database::account::update(pool, auth.current_user.unwrap().id, &payload).await { + Ok(user) => user, + Err(sqlx::Error::RowNotFound) => return (StatusCode::CONFLICT).into_response(), + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR).into_response(), + }; + + (StatusCode::OK, Json(updated_user)).into_response() +} + +/// Permanently delete the current logged in account +pub async fn delete_account( + State(pool): State, + auth: AuthContext, + Json(payload): Json, +) -> impl IntoResponse { + let account = match database::account::get_id(pool.clone(), auth.clone().current_user.unwrap().id).await { + Ok(user) => user, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR).into_response(), + }; + + // verify the current users legitimacy by checking the accounts password + if !hasher::verify(&account.password_hash, &payload.current_password) { + return (StatusCode::UNAUTHORIZED).into_response(); + } + + // delete the account from the database + let account = + match database::account::delete(pool, auth.clone().current_user.unwrap().id).await { + Ok(user) => user, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR).into_response(), + }; + + // after deletion log out the session + logout(auth).await; + + (StatusCode::OK, Json(account)).into_response() +} + +/// Change the password of the current logged in account +pub async fn edit_account_password( + State(pool): State, + auth: AuthContext, + Json(payload): Json, +) -> impl IntoResponse { + let account = match database::account::get_id(pool.clone(), auth.clone().current_user.unwrap().id).await { + Ok(user) => user, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR).into_response(), + }; + + // verify the current users legitimacy by checking the accounts password + if !hasher::verify(&account.password_hash, &payload.current_password) { + return (StatusCode::UNAUTHORIZED).into_response(); + } + + // delete the account from the database + let account = + match database::account::update_password(pool, auth.clone().current_user.unwrap().id, payload.new_password).await { + Ok(user) => user, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR).into_response(), + }; + + // after deletion log out the session + logout(auth).await; + + (StatusCode::OK, Json(account)).into_response() +} diff --git a/src/logic/activities.rs b/src/logic/activities.rs new file mode 100644 index 0000000..47e04ca --- /dev/null +++ b/src/logic/activities.rs @@ -0,0 +1,160 @@ +use crate::activity::{BareActivity, StringBareActivity}; +use crate::database; +use crate::logic::AuthContext; + +use axum::extract::{Path, State}; +use axum::response::IntoResponse; +use axum::Json; +use chrono::{DateTime, Utc}; +use http::StatusCode; +use sqlx::SqlitePool; +use crate::database::Error; + +/// Returns a single `Activity` by id +pub async fn get_activity( + State(pool): State, + Path(activity_id): Path, +) -> impl IntoResponse { + let activity = match database::activity::get(pool, activity_id).await { + Ok(activity) => activity, + Err(Error::SQLX(e)) => return match e { + sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND), + _ => (StatusCode::INTERNAL_SERVER_ERROR), + }.into_response(), + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR).into_response(), + }; + + (StatusCode::OK, Json(activity)).into_response() +} + +/// Returns a list of `Activity` which were started in an time interval of [:from, :to] +pub async fn get_activities_from_to( + State(pool): State, + Path((from, to)): Path<(String, String)>, +) -> impl IntoResponse { + // parse the :from parameter as a RFC-3339 DateTime String + // otherwise return an error + let from = match DateTime::parse_from_rfc3339(&from) { + Ok(time) => time.with_timezone(&Utc), + Err(_) => return (StatusCode::BAD_REQUEST, ":from url-parameter is not a valid rfc3339 format").into_response(), + }; + + // parse the :to parameter as a RFC-3339 DateTime String + // otherwise return an error + let to = match DateTime::parse_from_rfc3339(&to) { + Ok(time) => time.with_timezone(&Utc), + Err(_) => return (StatusCode::BAD_REQUEST, ":to url-parameter is not a valid rfc3339 format").into_response(), + }; + + if to < from { + return (StatusCode::BAD_REQUEST, "the :to time must be later than the :from time").into_response(); + } + + let activities = match database::activity::get_interval(pool, &from, &to).await { + Ok(activities) => activities, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR).into_response(), + }; + + (StatusCode::OK, Json(activities)).into_response() +} + +/// Creates a new `Activity` object +pub async fn post_activity( + State(pool): State, + auth: AuthContext, + Json(payload): Json, +) -> impl IntoResponse { + let start_time = match DateTime::parse_from_rfc3339(&payload.start_time) { + Ok(time) => time.with_timezone(&Utc), + Err(_) => return (StatusCode::BAD_REQUEST, ":from url-parameter is not a valid rfc3339 format").into_response(), + }; + + let end_time = match DateTime::parse_from_rfc3339(&payload.end_time) { + Ok(time) => time.with_timezone(&Utc), + Err(_) => return (StatusCode::BAD_REQUEST, ":from url-parameter is not a valid rfc3339 format").into_response(), + }; + + if end_time < start_time { + return (StatusCode::BAD_REQUEST, "the end_time time must be later than the start_time").into_response(); + } + + let author_id = auth.current_user.unwrap().id; + let new_activity = BareActivity { + amount: payload.amount, + activity_type: payload.activity_type, + start_time: start_time, + end_time: end_time, + }; + + let activity = match database::activity::insert(pool, author_id, &new_activity).await { + Ok(activity) => activity, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR).into_response(), + }; + + (StatusCode::CREATED, Json(activity)).into_response() +} + +/// Edits the information of an `Activity` object +pub async fn edit_activity( + State(pool): State, + auth: AuthContext, + Path(activity_id): Path, + Json(payload): Json, // todo string bare activity +) -> impl IntoResponse { + // get the referenced activity from the database + let activity = match database::activity::get(pool.clone(), activity_id).await { + Ok(activity) => activity, + Err(Error::SQLX(e)) => return match e { + sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND), + _ => (StatusCode::INTERNAL_SERVER_ERROR), + }.into_response(), + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR).into_response(), + }; + + let author_id = auth.current_user.unwrap().id; + + // only the activity author is allowed to delete its activities + if activity.author_id != author_id { + return (StatusCode::UNAUTHORIZED).into_response(); + } + + // update the activity in the database + let activity = match database::activity::update(pool, activity.id, &payload).await { + Ok(activity) => activity, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR).into_response(), + }; + + (StatusCode::OK, Json(activity)).into_response() +} + +/// Deletes an `Activity` object +pub async fn delete_activity( + State(pool): State, + auth: AuthContext, + Path(activity_id): Path, +) -> impl IntoResponse { + // get the referenced activity from the database + let activity = match database::activity::get(pool.clone(), activity_id).await { + Ok(activity) => activity, + Err(Error::SQLX(e)) => return match e { + sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND), + _ => (StatusCode::INTERNAL_SERVER_ERROR), + }.into_response(), + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR).into_response(), + }; + + let author_id = auth.current_user.unwrap().id; + + // only the activity author is allowed to delete its activities + if activity.author_id != author_id { + return (StatusCode::UNAUTHORIZED).into_response(); + } + + // delete the activity from the database + let activity = match database::activity::delete(pool, activity_id).await { + Ok(activity) => activity, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR).into_response(), + }; + + (StatusCode::OK, Json(activity)).into_response() +} diff --git a/src/logic/auth.rs b/src/logic/auth.rs new file mode 100644 index 0000000..7e5c965 --- /dev/null +++ b/src/logic/auth.rs @@ -0,0 +1,35 @@ +use crate::account::{Account, BareAccount}; +use crate::database; +use crate::hasher; + +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::Json; +use axum_login::SqliteStore; +use sqlx::SqlitePool; + +type AuthContext = axum_login::extractors::AuthContext>; + +pub async fn login( + State(pool): State, + mut auth: AuthContext, + Json(payload): Json, +) -> impl IntoResponse { + let user = match database::account::get(pool, &payload.username).await { + Ok(user) => user, + Err(_) => return (StatusCode::NOT_FOUND, "user with the name doesn't exist").into_response(), + }; + + if !hasher::verify(&user.password_hash, &payload.password) { + return (StatusCode::UNAUTHORIZED, "password doesn't match").into_response(); + } + + auth.login(&user).await.unwrap(); + (StatusCode::OK, Json(user)).into_response() +} + +pub async fn logout(mut auth: AuthContext) -> impl IntoResponse { + auth.logout().await; + (StatusCode::OK).into_response() +} diff --git a/src/logic/users.rs b/src/logic/users.rs new file mode 100644 index 0000000..0e9feee --- /dev/null +++ b/src/logic/users.rs @@ -0,0 +1,40 @@ +use crate::database; +use crate::database::Error; + +use axum::extract::{Path, State}; +use axum::response::IntoResponse; +use axum::Json; +use http::StatusCode; +use sqlx::SqlitePool; + +pub async fn get_user( + State(pool): State, + Path(username): Path, +) -> impl IntoResponse { + let user = match database::user::get(pool, &username).await { + Ok(user) => user, + Err(Error::SQLX(e)) => return match e { + sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND), + _ => (StatusCode::INTERNAL_SERVER_ERROR), + }.into_response(), + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR).into_response(), + }; + + (StatusCode::OK, Json(user)).into_response() +} + +pub async fn get_user_id( + State(pool): State, + Path(user_id): Path, +) -> impl IntoResponse { + let user = match database::user::get_id(pool, user_id).await { + Ok(user) => user, + Err(Error::SQLX(e)) => return match e { + sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND), + _ => (StatusCode::INTERNAL_SERVER_ERROR), + }.into_response(), + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR).into_response(), + }; + + (StatusCode::OK, Json(user)).into_response() +} diff --git a/src/main.rs b/src/main.rs index 3b013f9..115149c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,23 +1,22 @@ +mod account; mod activity; +mod database; mod hasher; mod logic; -mod services; -mod storage; -mod user; +mod routes; use axum::Router; #[tokio::main] async fn main() { // init database or exit program on error - match storage::init().await { - Ok(_) => {}, - Err(_) => panic!("Error while initializing the database."), - }; + let pool = database::init() + .await + .expect("Error while initializing the database."); let app = Router::new() - .merge(services::backend_router().await) - .merge(services::frontend_router().await); + .merge(routes::backend_router(pool).await) + .merge(routes::frontend_router().await); axum::Server::bind(&"0.0.0.0:3000".parse().unwrap()) .serve(app.into_make_service()) diff --git a/src/routes.rs b/src/routes.rs new file mode 100644 index 0000000..ca2843d --- /dev/null +++ b/src/routes.rs @@ -0,0 +1,80 @@ +use crate::account::Account; +use crate::logic::account::{ + delete_account, edit_account, edit_account_password, get_account, post_account, +}; +use crate::logic::activities::{ + delete_activity, edit_activity, get_activities_from_to, get_activity, post_activity, +}; +use crate::logic::auth::{login, logout}; +use crate::logic::users::{get_user, get_user_id}; + +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::routing::{delete, get, post, put}; +use axum::Router; +use axum_login::axum_sessions::async_session::MemoryStore; +use axum_login::axum_sessions::{SameSite, SessionLayer}; +use axum_login::{AuthLayer, RequireAuthorizationLayer, SqliteStore}; +use rand::Rng; +use sqlx::SqlitePool; +use tower_http::services::ServeDir; + +#[allow(clippy::unused_async)] +async fn handle_error() -> impl IntoResponse { + (StatusCode::INTERNAL_SERVER_ERROR).into_response() +} + +pub async fn frontend_router() -> Router { + Router::new().nest_service("/", ServeDir::new("public")) +} + +pub async fn backend_router(pool: SqlitePool) -> Router { + let secret = rand::thread_rng().gen::<[u8; 64]>(); // todo use secret from environment variable + + let session_store = MemoryStore::new(); + let session_layer = SessionLayer::new(session_store, &secret) + .with_secure(false) + .with_same_site_policy(SameSite::Lax); + let user_store = SqliteStore::::new(pool.clone()); + let auth_layer = AuthLayer::new(user_store, &secret); + + let auth_router = Router::new() + .route("/v1/auth/login", post(login)) + .route("/v1/auth/logout", put(logout)) + .layer(auth_layer.clone()) + .layer(session_layer.clone()); + + let account_router = Router::new() + .route("/v1/account", get(get_account)) + .route("/v1/account", put(edit_account)) + .route("/v1/account", delete(delete_account)) + .route("/v1/account/password", put(edit_account_password)) + .route_layer(RequireAuthorizationLayer::::login()) + .route("/v1/account", post(post_account)) + .layer(auth_layer.clone()) + .layer(session_layer.clone()); + + let users_router = Router::new() + .route("/v1/users/id/:id", get(get_user_id)) + .route("/v1/users/:username", get(get_user)) + .route_layer(RequireAuthorizationLayer::::login()) + .layer(auth_layer.clone()) + .layer(session_layer.clone()); + + let activities_router = Router::new() + .route("/v1/activities", post(post_activity)) + .route("/v1/activities/:id", get(get_activity)) + .route("/v1/activities/:id", put(edit_activity)) + .route("/v1/activities/:id", delete(delete_activity)) + .route("/v1/activities/:from/:to", get(get_activities_from_to)) + .route_layer(RequireAuthorizationLayer::::login()) + .layer(auth_layer.clone()) + .layer(session_layer.clone()); + + Router::new() + .merge(auth_router) + .merge(account_router) + .merge(users_router) + .merge(activities_router) + .with_state(pool) +} diff --git a/src/services.rs b/src/services.rs deleted file mode 100644 index 1369541..0000000 --- a/src/services.rs +++ /dev/null @@ -1,62 +0,0 @@ -use crate::logic::*; -use crate::user::User; - -use crate::storage::DB_URI; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::{delete, get, post}; -use axum::Router; -use axum_login::axum_sessions::async_session::MemoryStore; -use axum_login::axum_sessions::{SameSite, SessionLayer}; -use axum_login::{AuthLayer, RequireAuthorizationLayer, SqliteStore}; -use rand::Rng; -use sqlx::sqlite::SqlitePoolOptions; -use tower_http::services::ServeDir; - -#[allow(clippy::unused_async)] -async fn handle_error() -> impl IntoResponse { - (StatusCode::INTERNAL_SERVER_ERROR).into_response() -} - -pub async fn frontend_router() -> Router { - Router::new().nest_service("/", ServeDir::new("public")) -} - -pub async fn backend_router() -> Router { - let secret = rand::thread_rng().gen::<[u8; 64]>(); - - let session_store = MemoryStore::new(); - let session_layer = SessionLayer::new(session_store, &secret) - .with_secure(false) - .with_same_site_policy(SameSite::Lax); - - let pool = SqlitePoolOptions::new().connect(DB_URI).await.unwrap(); - - let user_store = SqliteStore::::new(pool); - let auth_layer = AuthLayer::new(user_store, &secret); - - Router::new() - // account management routes - .route("/v1/account", get(get_account)) - .route("/v1/account/edit", post(edit_account)) - .route("/v1/account", delete(delete_account)) - // user - .route("/v1/user/id/:id", get(get_user_by_id)) - .route("/v1/user/:username", get(get_user)) - // activity routes - .route("/v1/activities/:id", get(get_activity)) - .route("/v1/activities/:from/:to", get(get_activities_from_to)) - .route("/v1/activities", post(new_activity)) - .route("/v1/activities/edit", post(edit_activity)) - .route("/v1/activities/:id", delete(delete_activity)) - // for checking if you are logged in - .route("/v1/ping", get(ping)) - // routes above are protected - .route_layer(RequireAuthorizationLayer::::login()) - // authentication routes - .route("/v1/auth/sign_up", post(sign_up)) - .route("/v1/auth/sign_in", post(sign_in)) - .route("/v1/auth/sign_out", get(sign_out)) - .layer(auth_layer) - .layer(session_layer) -} diff --git a/src/storage.rs b/src/storage.rs deleted file mode 100644 index a878b2c..0000000 --- a/src/storage.rs +++ /dev/null @@ -1,197 +0,0 @@ -use crate::activity::{Activity, BareActivity}; -use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; -use sqlx::{ConnectOptions, Executor}; -use std::str::FromStr; -use chrono::{DateTime, Utc}; - -use crate::hasher; -use crate::user::{BareUser, User}; - -pub const DB_URI: &str = "data.db"; - -/// Error codes for the storage module -pub enum Error { - ElementNotFound, - InternalError, - NotImplemented, -} - -pub async fn init() -> Result<(), Error> { - let connection: SqliteConnectOptions = - match SqliteConnectOptions::from_str(&format!("sqlite://{}", DB_URI)) { - Ok(connection) => connection, - Err(_) => return Err(Error::InternalError), - }; - - let mut connection = match connection.create_if_missing(true).connect().await { - Ok(connection) => connection, - Err(_) => return Err(Error::InternalError), - }; - - // create users table - match connection.execute( - "CREATE TABLE IF NOT EXISTS 'users' ( - 'id' INTEGER UNIQUE, - 'name' TEXT NOT NULL UNIQUE, - 'password_hash' TEXT NOT NULL UNIQUE, - PRIMARY KEY('id' AUTOINCREMENT))") - .await - { - Ok(_) => {} - Err(_) => return Err(Error::InternalError), // todo return e - }; - - // create activities table - match connection.execute( - "CREATE TABLE IF NOT EXISTS 'activities' ( - 'id' INTEGER UNIQUE, - 'start_time' TEXT NOT NULL, - 'end_time' TEXT NOT NULL, - 'amount' INTEGER NOT NULL, - 'activity_type' TEXT NOT NULL, - 'author_id' INTEGER NOT NULL, - FOREIGN KEY('author_id') REFERENCES 'users'('id'), - PRIMARY KEY('id' AUTOINCREMENT))") - .await - { - Ok(_) => {} - Err(_) => return Err(Error::InternalError), // todo return e - }; - - Ok(()) -} - -pub async fn insert_new_user(user: &BareUser) -> Result { - let password_hash = hasher::hash(&user.password); - - let pool = SqlitePoolOptions::new().connect(DB_URI).await.unwrap(); - - let user: User = sqlx::query_as("insert into users (name, password_hash) values ($1, $2) returning *") - .bind(&user.name).bind(password_hash) - .fetch_one(&pool) - .await - .unwrap(); - - Ok(user) -} - -pub async fn get_user(name: &String) -> Result { - let pool = SqlitePoolOptions::new().connect(DB_URI).await.unwrap(); - - let user: User = match sqlx::query_as("select * from users where name = $1") - .bind(name.as_str()) - .fetch_one(&pool) - .await - { - Ok(user) => user, - Err(sqlx::Error::RowNotFound) => return Err(Error::ElementNotFound), - Err(_) => return Err(Error::InternalError), // todo return e - }; - - Ok(user) -} - -pub async fn get_user_by_id(id: &i64) -> Result { - let pool = SqlitePoolOptions::new().connect(DB_URI).await.unwrap(); - - let user: User = match sqlx::query_as("select * from users where id = $1") - .bind(id) - .fetch_one(&pool) - .await - { - Ok(user) => user, - Err(sqlx::Error::RowNotFound) => return Err(Error::ElementNotFound), - Err(_) => return Err(Error::InternalError), // todo return e - }; - - Ok(user) -} - -pub async fn user_exists(name: &String) -> bool { - let user = get_user(name).await; - match user { - Ok(_) => true, - Err(_) => false, - } -} - -pub async fn get_activity(id: i64) -> Result { - let pool = SqlitePoolOptions::new().connect(DB_URI).await.unwrap(); - - let activity: Activity = match sqlx::query_as("select * from activities where id = $1") - .bind(id) - .fetch_one(&pool) - .await - { - Ok(activity) => activity, - Err(sqlx::Error::RowNotFound) => return Err(Error::ElementNotFound), - Err(_) => { - return Err(Error::InternalError); - }, // todo return e - }; - - Ok(activity) -} - -pub async fn get_activities(from: &DateTime, to: &DateTime) -> Result, Error> { - let pool = SqlitePoolOptions::new().connect(DB_URI).await.unwrap(); - - match sqlx::query_as("SELECT * FROM activities WHERE start_time >= $1 and end_time <= $2") - .bind(from) - .bind(to) - .fetch_all(&pool) - .await - { - Ok(activities) => Ok(activities), - Err(_) => Err(Error::InternalError), - } -} - -/* -pub async fn get_all_activities() -> Result, Error> { - let pool = SqlitePoolOptions::new().connect(DB_URI).await.unwrap(); - - let activities: Vec = match sqlx::query_as("select * from activities") - .fetch_all(&pool) - .await - { - Ok(activities) => activities, - Err(sqlx::Error::RowNotFound) => return Err(Error::ElementNotFound), - Err(_) => return Err(Error::InternalError), // todo return e - }; - - Ok(activities) -} -*/ - -pub async fn new_activity(activity: &BareActivity, author: &User) -> Result { - let pool = SqlitePoolOptions::new().connect(DB_URI).await.unwrap(); - - let activity: Activity = sqlx::query_as("insert into activities (author_id, amount, activity_type, start_time, end_time) values ($1, $2, $3, $4, $5) returning *") - .bind(author.id) - .bind(activity.amount) - .bind(&activity.activity_type) - .bind(activity.start_time) - .bind(activity.end_time) - .fetch_one(&pool) - .await - .unwrap(); - - Ok(activity) -} - -pub async fn edit_activity(activity: &BareActivity) -> Result { - Err(Error::NotImplemented) -} - -pub async fn delete_activity(id: i64) -> Result { - let pool = SqlitePoolOptions::new().connect(DB_URI).await.unwrap(); - - let activity: Activity = sqlx::query_as("delete from activities where id = $1 returning *") - .bind(id) - .fetch_one(&pool) - .await - .unwrap(); - - Ok(activity) -} diff --git a/src/user.rs b/src/user.rs deleted file mode 100644 index 8d8e412..0000000 --- a/src/user.rs +++ /dev/null @@ -1,46 +0,0 @@ -use axum_login::secrecy::SecretVec; -use axum_login::AuthUser; -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; -use std::convert::From; -use std::convert::Into; - -#[derive(Clone, Debug, Default, Deserialize, Serialize, FromRow)] -pub struct User { - pub id: i64, - pub name: String, - pub password_hash: String, -} - -impl AuthUser for User { - fn get_id(&self) -> i64 { - self.id - } - - fn get_password_hash(&self) -> SecretVec { - SecretVec::new(self.password_hash.clone().into()) - } -} - -/// Like user, but instead of storing the password hash, it stores the plain-text password. -/// Is used when the user signs in or signs up. Should not be used outside of these routes. -#[derive(Deserialize)] -pub struct BareUser { - pub name: String, - pub password: String, -} - -#[derive(Serialize)] -pub struct PublicUser { - pub id: i64, - pub name: String, -} - -impl From for PublicUser { - fn from(user: User) -> Self { - Self { - id: user.id, - name: user.name, - } - } -}