diff --git a/Makefile b/Makefile index 97db62e..668ccf1 100644 --- a/Makefile +++ b/Makefile @@ -27,10 +27,6 @@ clean: remove-podman-containers cd backend && make clean @echo "Cleaned up!" -.PHONY: itest -itest: - python testing.py - # Cleans up everything related to podman, not just the project. Make sure you understand what this means. podman-clean: podman system reset --force diff --git a/backend/internal/database/db.go b/backend/internal/database/db.go index ef365cd..c13308b 100644 --- a/backend/internal/database/db.go +++ b/backend/internal/database/db.go @@ -4,6 +4,7 @@ import ( "embed" "os" "path/filepath" + "time" "ttime/internal/types" "github.com/jmoiron/sqlx" @@ -14,14 +15,13 @@ import ( type Database interface { // Insert a new user into the database, password should be hashed before calling AddUser(username string, password string) error - CheckUser(username string, password string) bool RemoveUser(username string) error PromoteToAdmin(username string) error GetUserId(username string) (int, error) AddProject(name string, description string, username string) error Migrate(dirname string) error GetProjectId(projectname string) (int, error) - AddWeeklyReport(projectName string, userName string, week int, developmentTime int, meetingTime int, adminTime int, ownWorkTime int, studyTime int, testingTime int) error + AddTimeReport(projectName string, userName string, activityType string, start time.Time, end time.Time) error AddUserToProject(username string, projectname string, role string) error ChangeUserRole(username string, projectname string, role string) error GetAllUsersProject(projectname string) ([]UserProjectMember, error) @@ -50,16 +50,27 @@ var scripts embed.FS const userInsert = "INSERT INTO users (username, password) VALUES (?, ?)" const projectInsert = "INSERT INTO projects (name, description, owner_user_id) SELECT ?, ?, id FROM users WHERE username = ?" const promoteToAdmin = "INSERT INTO site_admin (admin_id) SELECT id FROM users WHERE username = ?" -const addWeeklyReport = `WITH UserLookup AS (SELECT id FROM users WHERE username = ?), +const addTimeReport = `WITH UserLookup AS (SELECT id FROM users WHERE username = ?), ProjectLookup AS (SELECT id FROM projects WHERE name = ?) - INSERT INTO weekly_reports (project_id, user_id, week, development_time, meeting_time, admin_time, own_work_time, study_time, testing_time) - VALUES ((SELECT id FROM ProjectLookup), (SELECT id FROM UserLookup),?, ?, ?, ?, ?, ?, ?);` + INSERT INTO time_reports (project_id, user_id, activity_type, start, end) + VALUES ((SELECT id FROM ProjectLookup), (SELECT id FROM UserLookup),?, ?, ?);` const addUserToProject = "INSERT INTO user_roles (user_id, project_id, p_role) VALUES (?, ?, ?)" // WIP const changeUserRole = "UPDATE user_roles SET p_role = ? WHERE user_id = ? AND project_id = ?" -const getProjectsForUser = `SELECT projects.id, projects.name, projects.description, projects.owner_user_id - FROM projects JOIN user_roles ON projects.id = user_roles.project_id - JOIN users ON user_roles.user_id = users.id WHERE users.username = ?;` +const getProjectsForUser = ` +SELECT + projects.id, + projects.name, + projects.description, + projects.owner_user_id +FROM + projects +JOIN + user_roles ON projects.id = user_roles.project_id +JOIN + users ON user_roles.user_id = users.id +WHERE + users.username = ?;` // DbConnect connects to the database func DbConnect(dbpath string) Database { @@ -78,15 +89,6 @@ func DbConnect(dbpath string) Database { return &Db{db} } -func (d *Db) CheckUser(username string, password string) bool { - var dbPassword string - err := d.Get(&dbPassword, "SELECT password FROM users WHERE username = ?", username) - if err != nil { - return false - } - return dbPassword == password -} - // GetProjectsForUser retrieves all projects associated with a specific user. func (d *Db) GetProjectsForUser(username string) ([]types.Project, error) { var projects []types.Project @@ -108,8 +110,9 @@ func (d *Db) GetProject(projectId int) (types.Project, error) { return project, err } -func (d *Db) AddWeeklyReport(projectName string, userName string, week int, developmentTime int, meetingTime int, adminTime int, ownWorkTime int, studyTime int, testingTime int) error { - _, err := d.Exec(addWeeklyReport, userName, projectName, week, developmentTime, meetingTime, adminTime, ownWorkTime, studyTime, testingTime) +// AddTimeReport adds a time report for a specific project and user. +func (d *Db) AddTimeReport(projectName string, userName string, activityType string, start time.Time, end time.Time) error { // WIP + _, err := d.Exec(addTimeReport, userName, projectName, activityType, start, end) return err } diff --git a/backend/internal/database/db_test.go b/backend/internal/database/db_test.go index 5438d66..9118e2f 100644 --- a/backend/internal/database/db_test.go +++ b/backend/internal/database/db_test.go @@ -2,6 +2,7 @@ package database import ( "testing" + "time" ) // Tests are not guaranteed to be sequential @@ -92,7 +93,7 @@ func TestPromoteToAdmin(t *testing.T) { } } -func TestAddWeeklyReport(t *testing.T) { +func TestAddTimeReport(t *testing.T) { db, err := setupState() if err != nil { t.Error("setupState failed:", err) @@ -108,9 +109,12 @@ func TestAddWeeklyReport(t *testing.T) { t.Error("AddProject failed:", err) } - err = db.AddWeeklyReport("testproject", "testuser", 1, 1, 1, 1, 1, 1, 1) + var now = time.Now() + var then = now.Add(time.Hour) + + err = db.AddTimeReport("testproject", "testuser", "activity", now, then) if err != nil { - t.Error("AddWeeklyReport failed:", err) + t.Error("AddTimeReport failed:", err) } } @@ -130,9 +134,12 @@ func TestAddUserToProject(t *testing.T) { t.Error("AddProject failed:", err) } - err = db.AddWeeklyReport("testproject", "testuser", 1, 1, 1, 1, 1, 1, 1) + var now = time.Now() + var then = now.Add(time.Hour) + + err = db.AddTimeReport("testproject", "testuser", "activity", now, then) if err != nil { - t.Error("AddWeeklyReport failed:", err) + t.Error("AddTimeReport failed:", err) } err = db.AddUserToProject("testuser", "testproject", "user") diff --git a/backend/internal/database/migrations/0030_time_reports.sql b/backend/internal/database/migrations/0030_time_reports.sql new file mode 100644 index 0000000..7c169c2 --- /dev/null +++ b/backend/internal/database/migrations/0030_time_reports.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS time_reports ( + id INTEGER PRIMARY KEY, + project_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + activity_type TEXT NOT NULL, + start DATETIME NOT NULL, + end DATETIME NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + FOREIGN KEY (activity_type) REFERENCES activity_types (name) ON DELETE CASCADE +); + +CREATE TRIGGER IF NOT EXISTS time_reports_start_before_end + BEFORE INSERT ON time_reports + FOR EACH ROW + BEGIN + SELECT + CASE + WHEN NEW.start >= NEW.end THEN + RAISE (ABORT, 'start must be before end') + END; + END; \ No newline at end of file diff --git a/backend/internal/database/migrations/0035_weekly_report.sql b/backend/internal/database/migrations/0035_weekly_report.sql deleted file mode 100644 index 0e29b97..0000000 --- a/backend/internal/database/migrations/0035_weekly_report.sql +++ /dev/null @@ -1,14 +0,0 @@ -CREATE TABLE weekly_reports ( - user_id INTEGER, - project_id INTEGER, - week INTEGER, - development_time INTEGER, - meeting_time INTEGER, - admin_time INTEGER, - own_work_time INTEGER, - study_time INTEGER, - testing_time INTEGER, - FOREIGN KEY (user_id) REFERENCES users(id), - FOREIGN KEY (project_id) REFERENCES projects(id) - PRIMARY KEY (user_id, project_id, week) -) \ No newline at end of file diff --git a/backend/internal/database/migrations/0040_time_report_collections.sql b/backend/internal/database/migrations/0040_time_report_collections.sql new file mode 100644 index 0000000..be406ff --- /dev/null +++ b/backend/internal/database/migrations/0040_time_report_collections.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS report_collection ( + id INTEGER PRIMARY KEY, + owner_id INTEGER NOT NULL, + project_id INTEGER NOT NULL, + date DATE NOT NULL, + signed_by INTEGER, -- NULL if not signed + FOREIGN KEY (owner_id) REFERENCES users (id) + FOREIGN KEY (signed_by) REFERENCES users (id) +); \ No newline at end of file diff --git a/backend/internal/database/migrations/0070_salts.sql b/backend/internal/database/migrations/0070_salts.sql new file mode 100644 index 0000000..de9757d --- /dev/null +++ b/backend/internal/database/migrations/0070_salts.sql @@ -0,0 +1,16 @@ +-- It is unclear weather this table will be used + +-- Create the table to store hash salts +CREATE TABLE IF NOT EXISTS salts ( + id INTEGER PRIMARY KEY, + salt TEXT NOT NULL +); + +-- Commented out for now, no time for good practices, which is atrocious +-- Create a trigger to automatically generate a salt when inserting a new user record +-- CREATE TRIGGER generate_salt_trigger +-- AFTER INSERT ON users +-- BEGIN +-- INSERT INTO salts (salt) VALUES (randomblob(16)); +-- UPDATE users SET salt_id = (SELECT last_insert_rowid()) WHERE id = new.id; +-- END; diff --git a/backend/internal/database/migrations/0080_activity_types.sql b/backend/internal/database/migrations/0080_activity_types.sql new file mode 100644 index 0000000..d984d58 --- /dev/null +++ b/backend/internal/database/migrations/0080_activity_types.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS activity_types ( + name TEXT PRIMARY KEY +); + +INSERT OR IGNORE INTO activity_types (name) VALUES ('Development'); +INSERT OR IGNORE INTO activity_types (name) VALUES ('Meeting'); +INSERT OR IGNORE INTO activity_types (name) VALUES ('Administration'); +INSERT OR IGNORE INTO activity_types (name) VALUES ('Own Work'); +INSERT OR IGNORE INTO activity_types (name) VALUES ('Studies'); +INSErt OR IGNORE INTO activity_types (name) VALUES ('Testing'); \ No newline at end of file diff --git a/backend/internal/handlers/global_state.go b/backend/internal/handlers/global_state.go index 415b215..91d46a9 100644 --- a/backend/internal/handlers/global_state.go +++ b/backend/internal/handlers/global_state.go @@ -18,7 +18,6 @@ type GlobalState interface { LoginRenew(c *fiber.Ctx) error // To renew the token CreateProject(c *fiber.Ctx) error // To create a new project GetUserProjects(c *fiber.Ctx) error // To get all projects - SubmitWeeklyReport(c *fiber.Ctx) error // GetProject(c *fiber.Ctx) error // To get a specific project // UpdateProject(c *fiber.Ctx) error // To update a project // DeleteProject(c *fiber.Ctx) error // To delete a project @@ -78,17 +77,12 @@ func (gs *GState) Register(c *fiber.Ctx) error { // This path should obviously be protected in the future // UserDelete deletes a user from the database func (gs *GState) UserDelete(c *fiber.Ctx) error { - // Read from path parameters - username := c.Params("username") - - // Read username from Locals - auth_username := c.Locals("user").(*jwt.Token).Claims.(jwt.MapClaims)["name"].(string) - - if username != auth_username { - return c.Status(403).SendString("You can only delete yourself") + u := new(types.User) + if err := c.BodyParser(u); err != nil { + return c.Status(400).SendString(err.Error()) } - if err := gs.Db.RemoveUser(username); err != nil { + if err := gs.Db.RemoveUser(u.Username); err != nil { return c.Status(500).SendString(err.Error()) } @@ -106,20 +100,18 @@ func (gs *GState) IncrementButtonCount(c *fiber.Ctx) error { // Login is a simple login handler that returns a JWT token func (gs *GState) Login(c *fiber.Ctx) error { - // The body type is identical to a NewUser - u := new(types.NewUser) - if err := c.BodyParser(u); err != nil { - return c.Status(400).SendString(err.Error()) - } + // To test: curl --data "user=user&pass=pass" http://localhost:8080/api/login + user := c.FormValue("user") + pass := c.FormValue("pass") - if !gs.Db.CheckUser(u.Username, u.Password) { - println("User not found") + // Throws Unauthorized error + if user != "user" || pass != "pass" { return c.SendStatus(fiber.StatusUnauthorized) } // Create the Claims claims := jwt.MapClaims{ - "name": u.Username, + "name": user, "admin": false, "exp": time.Now().Add(time.Hour * 72).Unix(), } @@ -167,9 +159,9 @@ func (gs *GState) CreateProject(c *fiber.Ctx) error { // Get the username from the token and set it as the owner of the project // This is ugly but claims := user.Claims.(jwt.MapClaims) - owner := claims["name"].(string) + p.Owner = claims["name"].(string) - if err := gs.Db.AddProject(p.Name, p.Description, owner); err != nil { + if err := gs.Db.AddProject(p.Name, p.Description, p.Owner); err != nil { return c.Status(500).SendString(err.Error()) } @@ -255,21 +247,3 @@ func (gs *GState) GetProject(c *fiber.Ctx) error { // Return the project as JSON return c.JSON(project) } - -func (gs *GState) SubmitWeeklyReport(c *fiber.Ctx) error { - // Extract the necessary parameters from the token - user := c.Locals("user").(*jwt.Token) - claims := user.Claims.(jwt.MapClaims) - username := claims["name"].(string) - - report := new(types.NewWeeklyReport) - if err := c.BodyParser(report); err != nil { - return c.Status(400).SendString(err.Error()) - } - - if err := gs.Db.AddWeeklyReport(report.ProjectName, username, report.Week, report.DevelopmentTime, report.MeetingTime, report.AdminTime, report.OwnWorkTime, report.StudyTime, report.TestingTime); err != nil { - return c.Status(500).SendString(err.Error()) - } - - return c.Status(200).SendString("Time report added") -} diff --git a/backend/internal/types/WeeklyReport.go b/backend/internal/types/WeeklyReport.go deleted file mode 100644 index 23624db..0000000 --- a/backend/internal/types/WeeklyReport.go +++ /dev/null @@ -1,21 +0,0 @@ -package types - -// This is what should be submitted to the server, the username will be derived from the JWT token -type NewWeeklyReport struct { - // The name of the project, as it appears in the database - ProjectName string - // The week number - Week int - // Total time spent on development - DevelopmentTime int - // Total time spent in meetings - MeetingTime int - // Total time spent on administrative tasks - AdminTime int - // Total time spent on personal projects - OwnWorkTime int - // Total time spent on studying - StudyTime int - // Total time spent on testing - TestingTime int -} diff --git a/backend/internal/types/project.go b/backend/internal/types/project.go index 7e1747f..8fcfaf5 100644 --- a/backend/internal/types/project.go +++ b/backend/internal/types/project.go @@ -8,8 +8,9 @@ type Project struct { Owner string `json:"owner" db:"owner_user_id"` } -// As it arrives from the client, Owner is derived from the JWT token +// As it arrives from the client type NewProject struct { Name string `json:"name"` Description string `json:"description"` + Owner string `json:"owner"` } diff --git a/backend/internal/types/users.go b/backend/internal/types/users.go index e9dff67..233ec71 100644 --- a/backend/internal/types/users.go +++ b/backend/internal/types/users.go @@ -16,7 +16,6 @@ func (u *User) ToPublicUser() (*PublicUser, error) { }, nil } -// Should be used when registering, for example type NewUser struct { Username string `json:"username"` Password string `json:"password"` diff --git a/backend/main.go b/backend/main.go index 9ba2556..4e0935c 100644 --- a/backend/main.go +++ b/backend/main.go @@ -68,10 +68,9 @@ func main() { SigningKey: jwtware.SigningKey{Key: []byte("secret")}, })) - server.Post("/api/submitReport", gs.SubmitWeeklyReport) server.Get("/api/getUserProjects", gs.GetUserProjects) server.Post("/api/loginrenew", gs.LoginRenew) - server.Delete("/api/userdelete/:username", gs.UserDelete) // Perhaps just use POST to avoid headaches + server.Delete("/api/userdelete", gs.UserDelete) // Perhaps just use POST to avoid headaches server.Post("/api/project", gs.CreateProject) // Announce the port we are listening on and start the server diff --git a/testing.py b/testing.py deleted file mode 100644 index fa97567..0000000 --- a/testing.py +++ /dev/null @@ -1,75 +0,0 @@ -import requests -import string -import random - - -def randomString(len=10): - """Generate a random string of fixed length""" - letters = string.ascii_lowercase - return "".join(random.choice(letters) for i in range(len)) - - -# Defined once per test run -username = randomString() -token = None - -# The base URL of the API -base_url = "http://localhost:8080" - -# Endpoint to test -registerPath = base_url + "/api/register" -loginPath = base_url + "/api/login" -addProjectPath = base_url + "/api/project" - - -# Define a function to prform POST request with data and return response -def register(username: string, password: string): - print("Registering with username: ", username, " and password: ", password) - response = requests.post( - registerPath, json={"username": username, "password": password} - ) - print(response.text) - return response - - -def login(username: string, password: string): - print("Logging in with username: ", username, " and password: ", password) - response = requests.post( - loginPath, json={"username": username, "password": password} - ) - print(response.text) - return response - - -def test_login(): - response = login(username, "always_same") - assert response.status_code == 200, "Login failed" - print("Login successful") - return response.json()["token"] - - -# Define a function to test the POST request -def test_create_user(): - response = register(username, "always_same") - assert response.status_code == 200, "Registration failed" - print("Registration successful") - - -def test_add_project(): - loginResponse = login(username, "always_same") - token = loginResponse.json()["token"] - projectName = randomString() - response = requests.post( - addProjectPath, - json={"name": projectName, "description": "This is a project"}, - headers={"Authorization": "Bearer " + token}, - ) - print(response.text) - assert response.status_code == 200, "Add project failed" - print("Add project successful") - - -if __name__ == "__main__": - test_create_user() - test_login() - test_add_project()