Browse Source

Feature: Auth via Cookies; Todo creation

master
parent
commit
3d7c13e704
  1. 2
      .gitignore
  2. 0
      COPYING
  3. 33
      pages/base.html
  4. 111
      pages/category.html
  5. 12
      pages/error.html
  6. 344
      pages/index.html
  7. 4
      pages/login.html
  8. 1
      pages/register.html
  9. 270
      pages/todos.html
  10. 62
      scripts/api.js
  11. 25
      scripts/auth.js
  12. 3
      src/db/db.go
  13. 52
      src/db/group.go
  14. 28
      src/db/todo.go
  15. 51
      src/server/endpoints.go
  16. 42
      src/server/page.go
  17. 79
      src/server/server.go
  18. 49
      src/server/validation.go
  19. 4
      static/images/box-arrow-up-right.svg

2
.gitignore vendored

@ -0,0 +1,2 @@
dela.zip
bin/

33
pages/base.html

@ -12,21 +12,7 @@
<script src="/static/bootstrap/js/bootstrap.min.js"></script> <script src="/static/bootstrap/js/bootstrap.min.js"></script>
</head> </head>
<!-- <body class="d-flex flex-column h-100" style="width: 100%;"> -->
<body class="w-100 h-100"> <body class="w-100 h-100">
<!-- Header -->
<!-- <header class="d-flex flex-wrap align-items-center justify-content-center py-3 mb-4 border-bottom">
<div class="col-md-3 mb-2 mb-md-0">
<a href="/" class="d-inline-flex link-body-emphasis text-decoration-none">
<img width="64" height="64" src="/static/images/android-chrome-192x192.png" alt="Dela">
</a>
</div>
<div class="col-md-3 text-end" id="bar-auth">
<a href="/login" class="btn btn-outline-primary"><img src="/static/images/door-open-fill.svg" alt="Log in"></a>
<a href="/register" class="btn btn-outline-primary"><img src="/static/images/person-fill-add.svg" alt="Register"></a>
</div>
</header> -->
<header class="p-3 text-bg-primary"> <header class="p-3 text-bg-primary">
<div class="container"> <div class="container">
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start"> <div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
@ -49,8 +35,6 @@
</div> </div>
</header> </header>
<!-- Content --> <!-- Content -->
{{ template "content" . }} {{ template "content" . }}
@ -61,30 +45,23 @@
<script src="/scripts/api.js"></script> <script src="/scripts/api.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', async function() { document.addEventListener('DOMContentLoaded', async function() {
let login = getLogin(); if (document.cookie.indexOf("auth=") == -1) {
let password = getUserPassword(); if (window.location.pathname != "/about" && window.location.pathname != "/login" && window.location.pathname != "/register" && window.location.pathname != "/error") {
if (login == null | login == "" | password == null | password == "") {
if (window.location.pathname != "/about" && window.location.pathname != "/login" && window.location.pathname != "/register") {
window.location.replace("/about"); window.location.replace("/about");
} }
return; return;
} }
// Check if auth info is indeed valid // Check if auth info is indeed valid
let response = await getUser(login, password); let response = await getUser();
if (response.ok) { if (response.ok) {
let barAuth = document.getElementById("bar-auth"); let barAuth = document.getElementById("bar-auth");
barAuth.innerHTML = "<b>" + login + "</b>" + " | "; // barAuth.innerHTML = "<b>" + login + "</b>" + " | ";
barAuth.innerHTML += '<button id="log-out-btn" class="btn btn-outline-light me-2"><img src="/static/images/person-dash-fill.svg"></button>'; barAuth.innerHTML = '<button id="log-out-btn" class="btn btn-outline-light me-2"><img src="/static/images/person-dash-fill.svg"></button>';
document.getElementById("log-out-btn").addEventListener("click", (event) => { document.getElementById("log-out-btn").addEventListener("click", (event) => {
// Log out // Log out
forgetAuthInfo();
window.location.replace("/about"); window.location.replace("/about");
}); });
} else {
forgetAuthInfo();
window.location.replace("/about");
} }
}, false) }, false)
</script> </script>

111
pages/category.html

@ -0,0 +1,111 @@
{{ template "base" . }}
{{ define "content" }}
<h1 style="display: none;" id="categoryId">{{.CurrentGroupId}}</h1>
<!-- Main -->
<main class="d-flex flex-wrap">
<!-- Sidebar -->
<div id="sidebar" class="flex-shrink-1 p-2 d-flex flex-column align-items-stretch bg-body-tertiary" style="width: 380px;">
<a href="/" class="d-flex align-items-center flex-shrink-0 p-3 link-body-emphasis text-decoration-none border-bottom">
<svg class="bi pe-none me-2" width="30" height="24"><use xlink:href="#bootstrap"></use></svg>
<span class="fs-5 fw-semibold">Categories</span>
</a>
{{ range .Groups }}
<div class="list-group list-group-flush border-bottom scrollarea">
<a href="/group/{{.ID}}" class="list-group-item list-group-item-action active py-3 lh-sm" aria-current="true">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">{{ .Name }}</strong>
<small>{{ .TimeCreatedUnix }}</small>
</div>
<div class="col-10 mb-1 small">Is removable: {{ .Removable }}</div>
</a>
</div>
{{ end }}
</div>
<div class="p-2 flex-grow-1">
<form action="javascript:void(0);">
<div class="container">
<div class="row">
<div class="col">
<input type="text" class="form-control" id="new-todo-text" placeholder="TODO text">
</div>
<div class="col">
<input type="datetime-local" name="new-todo-due" id="new-todo-due" placeholder="Due">
</div>
<div class="col">
<button id="new-todo-submit" class="btn btn-primary">Add</button>
<button class="btn btn-secondary" id="show-done">Show Done</button>
</div>
</div>
</div>
</form>
<div class="container text-center">
<table class="table table-hover">
<thead>
<th>ToDo</th>
<th>Created</th>
<th>Due</th>
<th>Group Id</th>
</thead>
<tbody id="todos" class="text-break">
{{ range .Todos }}
<tr>
<td>{{ .Text }}</td>
<td>{{ .TimeCreatedUnix }}</td>
<td>{{ .DueUnix }}</td>
<td>{{ .GroupID }}</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
</main>
<script>
document.addEventListener('DOMContentLoaded', async function() {
document.getElementById("new-todo-text").focus();
let showDoneButton = document.getElementById("show-done");
showDoneButton.addEventListener("click", (event) => {
// Rename the button
showDoneButton.innerText = "Show To Do";
showDoneButton.className = "btn btn-success";
// Make it "reset to default"
showDoneButton.addEventListener("click", (event) => {
location.reload();
});
});
// "Add" button
document.getElementById("new-todo-submit").addEventListener("click", async (event) => {
let newTodoTextInput = document.getElementById("new-todo-text");
let newTodoText = newTodoTextInput.value;
if (newTodoText.length < 1) {
newTodoTextInput.setCustomValidity("At least one character is needed!");
return;
} else {
newTodoTextInput.setCustomValidity("");
}
newTodoTextInput.value = "";
let groupId = document.getElementById("categoryId").innerText;
// Make a request
let response = await postNewTodo({text: newTodoText, groupId: Number(groupId)});
if (response.ok) {
location.reload();
}
});
});
</script>
{{ end }}

12
pages/error.html

@ -0,0 +1,12 @@
{{ template "base" . }}
{{ define "content" }}
<main class="cover-container d-flex w-100 h-100 p-3 mx-auto flex-column">
<div class="p-2 flex-fill text-wrap text-center">
<h1>Error!</h1>
<p>You have encountered an error</p>
<p>Try to reload the page or try again later</p>
</div>
</main>
{{ end }}

344
pages/index.html

@ -9,262 +9,124 @@
<div id="sidebar" class="flex-shrink-1 p-2 d-flex flex-column align-items-stretch bg-body-tertiary" style="width: 380px;"> <div id="sidebar" class="flex-shrink-1 p-2 d-flex flex-column align-items-stretch bg-body-tertiary" style="width: 380px;">
<a href="/" class="d-flex align-items-center flex-shrink-0 p-3 link-body-emphasis text-decoration-none border-bottom"> <a href="/" class="d-flex align-items-center flex-shrink-0 p-3 link-body-emphasis text-decoration-none border-bottom">
<svg class="bi pe-none me-2" width="30" height="24"><use xlink:href="#bootstrap"></use></svg> <svg class="bi pe-none me-2" width="30" height="24"><use xlink:href="#bootstrap"></use></svg>
<span class="fs-5 fw-semibold">List group</span> <span class="fs-5 fw-semibold">Categories</span>
</a> </a>
{{ range .Groups }}
<div class="list-group list-group-flush border-bottom scrollarea"> <div class="list-group list-group-flush border-bottom scrollarea">
<a href="#" class="list-group-item list-group-item-action active py-3 lh-sm" aria-current="true"> <a href="/group/{{.ID}}" class="list-group-item list-group-item-action active py-3 lh-sm" aria-current="true">
<div class="d-flex w-100 align-items-center justify-content-between"> <div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">List group item heading</strong> <strong class="mb-1">{{ .Name }}</strong>
<small>Wed</small> <small>{{ .TimeCreatedUnix }}</small>
</div> </div>
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div> <div class="col-10 mb-1 small">Is removable: {{ .Removable }}</div>
</a>
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">List group item heading</strong>
<small class="text-body-secondary">Tues</small>
</div>
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div>
</a>
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">List group item heading</strong>
<small class="text-body-secondary">Mon</small>
</div>
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div>
</a>
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm" aria-current="true">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">List group item heading</strong>
<small class="text-body-secondary">Wed</small>
</div>
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div>
</a>
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">List group item heading</strong>
<small class="text-body-secondary">Tues</small>
</div>
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div>
</a>
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">List group item heading</strong>
<small class="text-body-secondary">Mon</small>
</div>
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div>
</a>
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm" aria-current="true">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">List group item heading</strong>
<small class="text-body-secondary">Wed</small>
</div>
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div>
</a>
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">List group item heading</strong>
<small class="text-body-secondary">Tues</small>
</div>
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div>
</a>
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">List group item heading</strong>
<small class="text-body-secondary">Mon</small>
</div>
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div>
</a>
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm" aria-current="true">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">List group item heading</strong>
<small class="text-body-secondary">Wed</small>
</div>
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div>
</a>
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">List group item heading</strong>
<small class="text-body-secondary">Tues</small>
</div>
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div>
</a>
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">List group item heading</strong>
<small class="text-body-secondary">Mon</small>
</div>
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div>
</a> </a>
</div> </div>
{{ end }}
</div> </div>
<div class="d-flex flex-column flex-md-row p-4 gap-4 py-md-5 align-items-center justify-content-center">
<div class="p-2 flex-grow-1"> <div class="list-group">
<form action="javascript:void(0);"> {{ range .Groups }}
<div class="container"> <a href="/group/{{.ID}}" class="list-group-item list-group-item-action d-flex gap-3 py-3" aria-current="true">
<div class="row"> <img src="/static/images/box-arrow-up-right.svg" alt="Go to this category" width="32" height="32">
<div class="col"> <div class="d-flex gap-2 w-100 justify-content-between">
<input type="text" class="form-control" id="new-todo-text" placeholder="TODO text"> <div>
</div> <h6 class="mb-0">{{ .Name }}</h6>
<div class="col"> <p class="mb-0 opacity-75">Jump here</p>
<button id="new-todo-submit" class="btn btn-primary">Add</button>
<button class="btn btn-secondary" id="show-done">Show Done</button>
</div> </div>
<small class="opacity-50 text-nowrap">{{ .TimeCreatedUnix }}</small>
</div> </div>
</a>
{{ end }}
</div> </div>
</form>
<div class="container text-center">
<table id="todos" class="table table-hover" style="word-wrap: break-word;">
</table>
</div>
</div> </div>
</main> </main>
<script> <script>
async function displayTodos(showDone) { // function todoBlock(todo, editable) {
let username = getUsername(); // let todoCompleteBtnID = "btn-complete-" + String(todo.id);
let password = getUserPassword(); // let todoDeleteBtnID = "btn-delete-" + String(todo.id);
// let todoEditBtnID = "btn-edit-" + String(todo.id);
// Fetch and display TODOs
let response = await getTodos(username, password); // // Display
let todosJson = await response.json(); // let timeCreated = new Date(todo.timeCreatedUnix * 1000);
let todosDisplayed = [];
// return "<tr><td>" + todo.text + "</td>" +
if (response.ok && todosJson != null) { // "<td>" + " " +
let todosDiv = document.getElementById("todos"); // timeCreated.getDate() + "/" +
// Clear what we've had before // (timeCreated.getMonth() + 1) + "/" +
todosDiv.innerHTML = ""; // timeCreated.getFullYear() + "</td>" +
// "<td><button class='btn btn-success' id='" + todoCompleteBtnID + "'>" +
todosJson.forEach((item) => { // "<img src='/static/images/check.svg'></button><button class='btn btn-danger' id='" +
if (showDone === true && item.isDone == true) { // todoDeleteBtnID + "'><img src='/static/images/trash3-fill.svg'></button></td></tr>";
// An already done Todo // }
let todoDeleteBtnID = "btn-delete-" + String(item.id); // async function displayTodos(showDone) {
// // Fetch and display TODOs
// Display // let response = await getTodos();
let timeCreated = new Date(item.timeCreatedUnix * 1000); // if (!response.ok) {
let timeDone = new Date(item.completionTimeUnix * 1000); // // window.location.replace("/error")
todosDiv.innerHTML += "<tr><td>" + item.text + "</td>" + // return;
"<td>" + " " + timeCreated.getDate() + "/" + (timeCreated.getMonth() + 1) + "/" + timeCreated.getFullYear() + " | " + // }
timeDone.getDate() + "/" + (timeDone.getMonth() + 1) + "/" + timeDone.getFullYear() + "</td>" +
"<td>" + "<button class='btn btn-danger' id='" + // let todosJson = await response.json();
todoDeleteBtnID + "'><img src='/static/images/trash3-fill.svg'></button></td></tr>"; // if (todosJson == null) {
// return;
// }
todosDisplayed.push({item: item, buttonDel: todoDeleteBtnID}); // let todosDisplayed = [];
// let todosDiv = document.getElementById("todos");
// // Clear what we've had before
} else if (showDone === false && item.isDone == false) { // todosDiv.innerHTML = "";
// A yet to be done Todo
// todosJson.forEach((item) => {
let todoCompleteBtnID = "btn-complete-" + String(item.id); // let todoBlk = todoBlock(item, item.isDone);
let todoDeleteBtnID = "btn-delete-" + String(item.id); // todosDiv.innerHTML += todoBlk;
let todoEditBtnID = "btn-edit-" + String(item.id); // });
// }
// Display
let timeCreated = new Date(item.timeCreatedUnix * 1000);
todosDiv.innerHTML += "<tr><td>" + item.text + "</td>" + // document.addEventListener('DOMContentLoaded', async function() {
"<td>" + " " + timeCreated.getDate() + "/" + (timeCreated.getMonth() + 1) + "/" + timeCreated.getFullYear() + "</td>" + // document.getElementById("new-todo-text").focus();
"<td><button class='btn btn-success' id='" + todoCompleteBtnID + "'>" +
"<img src='/static/images/check.svg'></button><button class='btn btn-danger' id='" + // let showDoneButton = document.getElementById("show-done");
todoDeleteBtnID + "'><img src='/static/images/trash3-fill.svg'></button></td></tr>"; // showDoneButton.addEventListener("click", (event) => {
// displayTodos(true); // Re-display without reloading
todosDisplayed.push({item: item, buttonDel: todoDeleteBtnID, buttonComplete: todoCompleteBtnID});
// // Rename the button
} // showDoneButton.innerText = "Show To Do";
}); // showDoneButton.className = "btn btn-success";
}
// // Make it "reset to default"
// showDoneButton.addEventListener("click", (event) => {
// Loop over all buttons (doesn't matter which ones because the amounts are equal) // location.reload();
for (let i = 0; i < todosDisplayed.length; i++) { // });
let elem = todosDisplayed[i]; // });
if (showDone === false && elem.item.isDone === false) {
// Done button // // "Add" button
document.getElementById(elem.buttonComplete).addEventListener("click", async (event) => { // document.getElementById("new-todo-submit").addEventListener("click", async (event) => {
// Mark as done // let newTodoTextInput = document.getElementById("new-todo-text");
elem.item.isDone = true; // let newTodoText = newTodoTextInput.value;
// Set completion time // if (newTodoText.length < 1) {
elem.item.completionTimeUnix = Math.floor(Date.now() / 1000); // newTodoTextInput.setCustomValidity("At least one character is needed!");
// return;
// Update // } else {
response = await updateTodo(username, password, elem.item.id, elem.item); // newTodoTextInput.setCustomValidity("");
if (response.ok) { // }
location.reload(); // newTodoTextInput.value = "";
}
}); // // Make a request
// let response = await postNewTodo({text: newTodoText, groupId: groupId});
// Delete button // if (response.ok) {
document.getElementById(elem.buttonDel).addEventListener("click", async (event) => { // location.reload();
response = await deleteTodo(username, password, elem.item.id); // }
if (response.ok) { // });
location.reload();
}
}); // // Fetch and display TODOs
} else { // await displayTodos(false);
// Delete button // }, false)
document.getElementById(elem.buttonDel).addEventListener("click", async (event) => {
response = await deleteTodo(username, password, elem.item.id);
if (response.ok) {
location.reload();
}
});
}
}
}
document.addEventListener('DOMContentLoaded', async function() {
let username = getUsername();
let password = getUserPassword();
document.getElementById("new-todo-text").focus();
let showDoneButton = document.getElementById("show-done");
showDoneButton.addEventListener("click", (event) => {
displayTodos(true); // Re-display without reloading
// Rename the button
showDoneButton.innerText = "Show To Do";
showDoneButton.className = "btn btn-success";
// Make it "reset to default"
showDoneButton.addEventListener("click", (event) => {
location.reload();
});
});
// "Add" button
document.getElementById("new-todo-submit").addEventListener("click", async (event) => {
let newTodoTextInput = document.getElementById("new-todo-text");
let newTodoText = newTodoTextInput.value;
if (newTodoText.length < 1) {
newTodoTextInput.setCustomValidity("At least one character is needed!");
return;
} else {
newTodoTextInput.setCustomValidity("");
}
newTodoTextInput.value = "";
// Make a request
let response = await postNewTodo(username, password, {text: newTodoText});
if (response.ok) {
location.reload();
}
});
// Fetch and display TODOs
await displayTodos(false);
}, false)
</script> </script>
{{ end }} {{ end }}

4
pages/login.html

@ -44,10 +44,8 @@ async function logIn() {
password = sha256(password); password = sha256(password);
// Check if auth info is indeed valid // Check if auth info is indeed valid
let response = await getUser(login, password); let response = await getUser();
if (response.ok) { if (response.ok) {
rememberAuthInfo(login, password);
window.location.replace("/"); window.location.replace("/");
} else { } else {
document.getElementById("error_message").innerText = await response.text(); document.getElementById("error_message").innerText = await response.text();

1
pages/register.html

@ -59,7 +59,6 @@ async function register() {
let response = await postNewUser(postData); let response = await postNewUser(postData);
if (response.ok) { if (response.ok) {
rememberAuthInfo(postData.login, postData.password);
window.location.replace("/"); window.location.replace("/");
} else { } else {
document.getElementById("error_message").innerText = await response.text(); document.getElementById("error_message").innerText = await response.text();

270
pages/todos.html

@ -1,270 +0,0 @@
{{ template "base" . }}
{{ define "content" }}
<!-- Sidebar -->
<div id="sidebar" class="flex-shrink-1 p-2 d-flex flex-column align-items-stretch bg-body-tertiary" style="width: 380px;">
<a href="/" class="d-flex align-items-center flex-shrink-0 p-3 link-body-emphasis text-decoration-none border-bottom">
<svg class="bi pe-none me-2" width="30" height="24"><use xlink:href="#bootstrap"></use></svg>
<span class="fs-5 fw-semibold">List group</span>
</a>
<div class="list-group list-group-flush border-bottom scrollarea">
<a href="#" class="list-group-item list-group-item-action active py-3 lh-sm" aria-current="true">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">List group item heading</strong>
<small>Wed</small>
</div>
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div>
</a>
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">List group item heading</strong>
<small class="text-body-secondary">Tues</small>
</div>
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div>
</a>
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">List group item heading</strong>
<small class="text-body-secondary">Mon</small>
</div>
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div>
</a>
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm" aria-current="true">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">List group item heading</strong>
<small class="text-body-secondary">Wed</small>
</div>
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div>
</a>
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">List group item heading</strong>
<small class="text-body-secondary">Tues</small>
</div>
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div>
</a>
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">List group item heading</strong>
<small class="text-body-secondary">Mon</small>
</div>
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div>
</a>
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm" aria-current="true">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">List group item heading</strong>
<small class="text-body-secondary">Wed</small>
</div>
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div>
</a>
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">List group item heading</strong>
<small class="text-body-secondary">Tues</small>
</div>
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div>
</a>
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">List group item heading</strong>
<small class="text-body-secondary">Mon</small>
</div>
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div>
</a>
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm" aria-current="true">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">List group item heading</strong>
<small class="text-body-secondary">Wed</small>
</div>
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div>
</a>
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">List group item heading</strong>
<small class="text-body-secondary">Tues</small>
</div>
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div>
</a>
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">List group item heading</strong>
<small class="text-body-secondary">Mon</small>
</div>
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div>
</a>
</div>
</div>
<form action="javascript:void(0);">
<div class="container text-center">
<div class="row">
<div class="col">
<input type="text" class="form-control" id="new-todo-text" placeholder="TODO text">
</div>
<div class="col">
<button id="new-todo-submit" class="btn btn-primary">Add</button>
<button class="btn btn-secondary" id="show-done">Show Done</button>
</div>
</div>
</div>
</form>
<div class="container text-center" style="margin-top: 4ch;">
<table id="todos" class="table table-hover" style="word-wrap: break-word;">
{{ for .Todos }}
<tr>
{{ .Name }}
</tr>
{{ end }}
</table>
</div>
<script>
async function displayTodos(showDone) {
let username = getUsername();
let password = getUserPassword();
// Fetch and display TODOs
let response = await getTodos(username, password);
let todosJson = await response.json();
let todosDisplayed = [];
if (response.ok && todosJson != null) {
let todosDiv = document.getElementById("todos");
// Clear what we've had before
todosDiv.innerHTML = "";
todosJson.forEach((item) => {
if (showDone === true && item.isDone == true) {
// An already done Todo
let todoDeleteBtnID = "btn-delete-" + String(item.id);
// Display
let timeCreated = new Date(item.timeCreatedUnix * 1000);
let timeDone = new Date(item.completionTimeUnix * 1000);
todosDiv.innerHTML += "<tr><td>" + item.text + "</td>" +
"<td>" + " " + timeCreated.getDate() + "/" + (timeCreated.getMonth() + 1) + "/" + timeCreated.getFullYear() + " | " +
timeDone.getDate() + "/" + (timeDone.getMonth() + 1) + "/" + timeDone.getFullYear() + "</td>" +
"<td>" + "<button class='btn btn-danger' id='" +
todoDeleteBtnID + "'><img src='/static/images/trash3-fill.svg'></button></td></tr>";
todosDisplayed.push({item: item, buttonDel: todoDeleteBtnID});
} else if (showDone === false && item.isDone == false) {
// A yet to be done Todo
let todoCompleteBtnID = "btn-complete-" + String(item.id);
let todoDeleteBtnID = "btn-delete-" + String(item.id);
let todoEditBtnID = "btn-edit-" + String(item.id);
// Display
let timeCreated = new Date(item.timeCreatedUnix * 1000);
todosDiv.innerHTML += "<tr><td>" + item.text + "</td>" +
"<td>" + " " + timeCreated.getDate() + "/" + (timeCreated.getMonth() + 1) + "/" + timeCreated.getFullYear() + "</td>" +
"<td><button class='btn btn-success' id='" + todoCompleteBtnID + "'>" +
"<img src='/static/images/check.svg'></button><button class='btn btn-danger' id='" +
todoDeleteBtnID + "'><img src='/static/images/trash3-fill.svg'></button></td></tr>";
todosDisplayed.push({item: item, buttonDel: todoDeleteBtnID, buttonComplete: todoCompleteBtnID});
}
});
}
// Loop over all buttons (doesn't matter which ones because the amounts are equal)
for (let i = 0; i < todosDisplayed.length; i++) {
let elem = todosDisplayed[i];
if (showDone === false && elem.item.isDone === false) {
// Done button
document.getElementById(elem.buttonComplete).addEventListener("click", async (event) => {
// Mark as done
elem.item.isDone = true;
// Set completion time
elem.item.completionTimeUnix = Math.floor(Date.now() / 1000);
// Update
response = await updateTodo(username, password, elem.item.id, elem.item);
if (response.ok) {
location.reload();
}
});
// Delete button
document.getElementById(elem.buttonDel).addEventListener("click", async (event) => {
response = await deleteTodo(username, password, elem.item.id);
if (response.ok) {
location.reload();
}
});
} else {
// Delete button
document.getElementById(elem.buttonDel).addEventListener("click", async (event) => {
response = await deleteTodo(username, password, elem.item.id);
if (response.ok) {
location.reload();
}
});
}
}
}
document.addEventListener('DOMContentLoaded', async function() {
let username = getUsername();
let password = getUserPassword();
document.getElementById("new-todo-text").focus();
let showDoneButton = document.getElementById("show-done");
showDoneButton.addEventListener("click", (event) => {
displayTodos(true); // Re-display without reloading
// Rename the button
showDoneButton.innerText = "Show To Do";
showDoneButton.className = "btn btn-success";
// Make it "reset to default"
showDoneButton.addEventListener("click", (event) => {
location.reload();
});
});
// "Add" button
document.getElementById("new-todo-submit").addEventListener("click", async (event) => {
let newTodoTextInput = document.getElementById("new-todo-text");
let newTodoText = newTodoTextInput.value;
if (newTodoText.length < 1) {
newTodoTextInput.setCustomValidity("At least one character is needed!");
return;
} else {
newTodoTextInput.setCustomValidity("");
}
newTodoTextInput.value = "";
// Make a request
let response = await postNewTodo(username, password, {text: newTodoText});
if (response.ok) {
location.reload();
}
});
// Fetch and display TODOs
await displayTodos(false);
}, false)
</script>
{{ end }}

62
scripts/api.js

@ -1,83 +1,83 @@
/* /*
2023 Kasyanov Nikolay Alexeyevich (Unbewohnte) 2024 Kasyanov Nikolay Alexeyevich (Unbewohnte)
*/ */
async function post(url, json) {
async function post(url, login, password, json) {
return fetch(url, { return fetch(url, {
method: "POST", method: "POST",
credentials: "include",
headers: { headers: {
"Authorization": "Basic " + btoa(login + ":" + password),
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify(json) body: JSON.stringify(json)
}) })
} }
async function postNewTodo(login, password, newTodo) {
return post("/api/todo/create", login, password, newTodo) async function postNewTodo(newTodo) {
return post("/api/todo/create", newTodo)
} }
async function postNewGroup(login, password, newGroup) { async function postNewGroup(newGroup) {
return post("/api/group/create", login, password, newGroup) return post("/api/group/create", newGroup)
} }
async function postNewUser(newUser) { async function postNewUser(newUser) {
return post("/api/user/create", "", "", newUser) return post("/api/user/create", newUser)
} }
async function get(url, login, password) { async function get(url) {
return fetch(url, { return fetch(url, {
method: "GET", method: "GET",
credentials: "include",
headers: { headers: {
"Authorization": "Basic " + btoa(login + ":" + password),
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}) })
} }
async function getUser(login, password) { async function getUser() {
return get("/api/user/get", login, password); return get("/api/user/get");
} }
async function getTodos(login, password) { async function getTodos() {
return get("/api/todo/get", login, password); return get("/api/todo/get");
} }
async function getGroup(login, password) { async function getGroup() {
return get("/api/group/get", login, password); return get("/api/group/get");
} }
async function getAllGroups(login, password) { async function getAllGroups() {
return get("/api/user/get", login, password); return get("/api/user/get");
} }
async function del(url, login, password) { async function del(url) {
return fetch(url, { return fetch(url, {
method: "DELETE", method: "DELETE",
credentials: "include",
headers: { headers: {
"Authorization": "Basic " + btoa(login + ":" + password),
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}) })
} }
async function deleteTodo(login, password, id) { async function deleteTodo(id) {
return del("/api/todo/delete/"+id, login, password); return del("/api/todo/delete/"+id);
} }
async function update(url, login, password, json) { async function update(url, json) {
return post(url, login, password, json); return post(url, json);
} }
async function updateTodo(login, password, id, updatedTodo) { async function updateTodo(id, updatedTodo) {
return update("/api/todo/update/"+id, login, password, updatedTodo); return update("/api/todo/update/"+id, updatedTodo);
} }
async function updateGroup(login, password, id, updatedGroup) { async function updateGroup(id, updatedGroup) {
return update("/api/group/update/"+id, login, password, updateGroup); return update("/api/group/update/"+id, updatedGroup);
} }
async function updateUser(login, password, updatedUser) { async function updateUser(updatedUser) {
return update("/api/group/update/"+login, login, password, updatedUser); return update("/api/user/update", updatedUser);
} }

25
scripts/auth.js

@ -1,30 +1,7 @@
/* /*
2023 Kasyanov Nikolay Alexeyevich (Unbewohnte) 2024 Kasyanov Nikolay Alexeyevich (Unbewohnte)
*/ */
// Saves auth information to local storage
function rememberAuthInfo(login, password) {
localStorage.setItem("login", login);
localStorage.setItem("password", password);
}
// Retrieves user's password from local storage
function getUserPassword() {
return localStorage.getItem("password");
}
// Retrieves user's login from local storage
function getLogin() {
return localStorage.getItem("login");
}
// Removes all auth information from local storage
function forgetAuthInfo() {
localStorage.removeItem("login");
localStorage.removeItem("password");
}
/** /**
* [js-sha256]{@link https://github.com/emn178/js-sha256} * [js-sha256]{@link https://github.com/emn178/js-sha256}
* *

3
src/db/db.go

@ -1,6 +1,6 @@
/* /*
dela - web TODO list dela - web TODO list
Copyright (C) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte) Copyright (C) 2023, 2024 Kasyanov Nikolay Alexeyevich (Unbewohnte)
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by it under the terms of the GNU Affero General Public License as published by
@ -48,6 +48,7 @@ func setUpTables(db *DB) error {
name TEXT, name TEXT,
time_created_unix INTEGER, time_created_unix INTEGER,
owner_login TEXT NOT NULL, owner_login TEXT NOT NULL,
removable INTEGER,
FOREIGN KEY(owner_login) REFERENCES users(login))`, FOREIGN KEY(owner_login) REFERENCES users(login))`,
) )
if err != nil { if err != nil {

52
src/db/group.go

@ -1,3 +1,21 @@
/*
dela - web TODO list
Copyright (C) 2023, 2024 Kasyanov Nikolay Alexeyevich (Unbewohnte)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package db package db
import "database/sql" import "database/sql"
@ -8,15 +26,26 @@ type TodoGroup struct {
Name string `json:"name"` Name string `json:"name"`
TimeCreatedUnix uint64 `json:"timeCreatedUnix"` TimeCreatedUnix uint64 `json:"timeCreatedUnix"`
OwnerLogin string `json:"ownerLogin"` OwnerLogin string `json:"ownerLogin"`
Removable bool `json:"removable"`
}
func NewTodoGroup(name string, timeCreatedUnix uint64, ownerLogin string, removable bool) TodoGroup {
return TodoGroup{
Name: name,
TimeCreatedUnix: timeCreatedUnix,
OwnerLogin: ownerLogin,
Removable: removable,
}
} }
// Creates a new TODO group in the database // Creates a new TODO group in the database
func (db *DB) CreateTodoGroup(group TodoGroup) error { func (db *DB) CreateTodoGroup(group TodoGroup) error {
_, err := db.Exec( _, err := db.Exec(
"INSERT INTO todo_groups(name, time_created_unix, owner_username) VALUES(?, ?, ?)", "INSERT INTO todo_groups(name, time_created_unix, owner_login, removable) VALUES(?, ?, ?, ?)",
group.Name, group.Name,
group.TimeCreatedUnix, group.TimeCreatedUnix,
group.OwnerLogin, group.OwnerLogin,
group.Removable,
) )
return err return err
@ -29,6 +58,7 @@ func scanTodoGroup(rows *sql.Rows) (*TodoGroup, error) {
&newTodoGroup.Name, &newTodoGroup.Name,
&newTodoGroup.TimeCreatedUnix, &newTodoGroup.TimeCreatedUnix,
&newTodoGroup.OwnerLogin, &newTodoGroup.OwnerLogin,
&newTodoGroup.Removable,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@ -78,6 +108,26 @@ func (db *DB) GetTodoGroups() ([]*TodoGroup, error) {
return groups, nil return groups, nil
} }
func (db *DB) GetGroupTodos(groupId uint64) ([]*Todo, error) {
rows, err := db.Query("SELECT * FROM todos WHERE group_id=?", groupId)
if err != nil {
return nil, err
}
defer rows.Close()
var todos []*Todo
for rows.Next() {
todoGroup, err := scanTodo(rows)
if err != nil {
return todos, err
}
todos = append(todos, todoGroup)
}
return todos, nil
}
// Deletes information about a TODO group of given ID from the database // Deletes information about a TODO group of given ID from the database
func (db *DB) DeleteTodoGroup(id uint64) error { func (db *DB) DeleteTodoGroup(id uint64) error {
_, err := db.Exec( _, err := db.Exec(

28
src/db/todo.go

@ -1,6 +1,6 @@
/* /*
dela - web TODO list dela - web TODO list
Copyright (C) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte) Copyright (C) 2023, 2024 Kasyanov Nikolay Alexeyevich (Unbewohnte)
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by it under the terms of the GNU Affero General Public License as published by
@ -95,7 +95,7 @@ func (db *DB) GetTodos() ([]*Todo, error) {
// Creates a new TODO in the database // Creates a new TODO in the database
func (db *DB) CreateTodo(todo Todo) error { func (db *DB) CreateTodo(todo Todo) error {
_, err := db.Exec( _, err := db.Exec(
"INSERT INTO todos(group_id, text, time_created_unix, due_unix, owner_username, is_done, completion_time_unix) VALUES(?, ?, ?, ?, ?, ?, ?)", "INSERT INTO todos(group_id, text, time_created_unix, due_unix, owner_login, is_done, completion_time_unix) VALUES(?, ?, ?, ?, ?, ?, ?)",
todo.GroupID, todo.GroupID,
todo.Text, todo.Text,
todo.TimeCreatedUnix, todo.TimeCreatedUnix,
@ -134,12 +134,12 @@ func (db *DB) UpdateTodo(todoID uint64, updatedTodo Todo) error {
} }
// Searches and retrieves TODO groups created by the user // Searches and retrieves TODO groups created by the user
func (db *DB) GetAllUserTodoGroups(username string) ([]*TodoGroup, error) { func (db *DB) GetAllUserTodoGroups(login string) ([]*TodoGroup, error) {
var todoGroups []*TodoGroup var todoGroups []*TodoGroup
rows, err := db.Query( rows, err := db.Query(
"SELECT * FROM todo_groups WHERE owner_username=?", "SELECT * FROM todo_groups WHERE owner_login=?",
username, login,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@ -158,12 +158,12 @@ func (db *DB) GetAllUserTodoGroups(username string) ([]*TodoGroup, error) {
} }
// Searches and retrieves TODOs created by the user // Searches and retrieves TODOs created by the user
func (db *DB) GetAllUserTodos(username string) ([]*Todo, error) { func (db *DB) GetAllUserTodos(login string) ([]*Todo, error) {
var todos []*Todo var todos []*Todo
rows, err := db.Query( rows, err := db.Query(
"SELECT * FROM todos WHERE owner_username=?", "SELECT * FROM todos WHERE owner_login=?",
username, login,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@ -183,20 +183,20 @@ func (db *DB) GetAllUserTodos(username string) ([]*Todo, error) {
} }
// Deletes all information regarding TODOs of specified user // Deletes all information regarding TODOs of specified user
func (db *DB) DeleteAllUserTodos(username string) error { func (db *DB) DeleteAllUserTodos(login string) error {
_, err := db.Exec( _, err := db.Exec(
"DELETE FROM todos WHERE owner_username=?", "DELETE FROM todos WHERE owner_login=?",
username, login,
) )
return err return err
} }
// Deletes all information regarding TODO groups of specified user // Deletes all information regarding TODO groups of specified user
func (db *DB) DeleteAllUserTodoGroups(username string) error { func (db *DB) DeleteAllUserTodoGroups(login string) error {
_, err := db.Exec( _, err := db.Exec(
"DELETE FROM todo_groups WHERE owner_username=?", "DELETE FROM todo_groups WHERE owner_login=?",
username, login,
) )
return err return err

51
src/server/endpoints.go

@ -1,6 +1,6 @@
/* /*
dela - web TODO list dela - web TODO list
Copyright (C) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte) Copyright (C) 2023, 2024 Kasyanov Nikolay Alexeyevich (Unbewohnte)
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by it under the terms of the GNU Affero General Public License as published by
@ -22,6 +22,7 @@ import (
"Unbewohnte/dela/db" "Unbewohnte/dela/db"
"Unbewohnte/dela/logger" "Unbewohnte/dela/logger"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"net/http" "net/http"
"path" "path"
@ -70,12 +71,36 @@ func (s *Server) EndpointUserCreate(w http.ResponseWriter, req *http.Request) {
} }
logger.Info("[Server][EndpointUserCreate] Created a new user with login \"%s\"", user.Login) logger.Info("[Server][EndpointUserCreate] Created a new user with login \"%s\"", user.Login)
// Create a non-removable default category
err = s.db.CreateTodoGroup(db.NewTodoGroup(
"Notes",
uint64(time.Now().Unix()),
user.Login,
false,
))
if err != nil {
http.Error(w, "Failed to create default group", http.StatusInternalServerError)
logger.Error("[Server][EndpojntUserCreate] Failed to create a default group for %s: %s", user.Login, err)
return
}
// Send cookie
http.SetCookie(w, &http.Cookie{
Name: "auth",
Value: fmt.Sprintf("%s:%s", user.Login, user.Password),
SameSite: http.SameSiteStrictMode,
HttpOnly: false,
Path: "/",
Secure: true,
})
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
func (s *Server) EndpointUserUpdate(w http.ResponseWriter, req *http.Request) { func (s *Server) EndpointUserUpdate(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost { if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
} }
// Retrieve user data // Retrieve user data
@ -103,7 +128,7 @@ func (s *Server) EndpointUserUpdate(w http.ResponseWriter, req *http.Request) {
} }
// Check whether the user in request is the user specified in JSON // Check whether the user in request is the user specified in JSON
login, _, _ := req.BasicAuth() login := GetLoginFromReq(req)
if login != user.Login { if login != user.Login {
// Gotcha! // Gotcha!
logger.Warning("[Server][EndpointUserUpdate] %s tried to update user information of %s!", login, user.Login) logger.Warning("[Server][EndpointUserUpdate] %s tried to update user information of %s!", login, user.Login)
@ -138,7 +163,7 @@ func (s *Server) EndpointUserDelete(w http.ResponseWriter, req *http.Request) {
} }
// Delete // Delete
login, _, _ := req.BasicAuth() login := GetLoginFromReq(req)
err := s.db.DeleteUser(login) err := s.db.DeleteUser(login)
if err != nil { if err != nil {
http.Error(w, "Failed to delete user", http.StatusInternalServerError) http.Error(w, "Failed to delete user", http.StatusInternalServerError)
@ -165,7 +190,7 @@ func (s *Server) EndpointUserGet(w http.ResponseWriter, req *http.Request) {
} }
// Get information from the database // Get information from the database
login, _, _ := req.BasicAuth() login := GetLoginFromReq(req)
userDB, err := s.db.GetUser(login) userDB, err := s.db.GetUser(login)
if err != nil { if err != nil {
logger.Error("[Server][EndpointUserGet] Failed to retrieve information on \"%s\": %s", login, err) logger.Error("[Server][EndpointUserGet] Failed to retrieve information on \"%s\": %s", login, err)
@ -207,7 +232,7 @@ func (s *Server) EndpointTodoUpdate(w http.ResponseWriter, req *http.Request) {
} }
// Check if the user owns this TODO // Check if the user owns this TODO
if !s.db.DoesUserOwnTodo(todoID, GetLoginFromAuth(req)) { if !s.db.DoesUserOwnTodo(todoID, GetLoginFromReq(req)) {
http.Error(w, "You don't own this TODO", http.StatusForbidden) http.Error(w, "You don't own this TODO", http.StatusForbidden)
return return
} }
@ -265,7 +290,7 @@ func (s *Server) EndpointTodoDelete(w http.ResponseWriter, req *http.Request) {
} }
// Check if the user owns this TODO // Check if the user owns this TODO
if !s.db.DoesUserOwnTodo(todoID, GetLoginFromAuth(req)) { if !s.db.DoesUserOwnTodo(todoID, GetLoginFromReq(req)) {
http.Error(w, "You don't own this TODO", http.StatusForbidden) http.Error(w, "You don't own this TODO", http.StatusForbidden)
return return
} }
@ -273,7 +298,7 @@ func (s *Server) EndpointTodoDelete(w http.ResponseWriter, req *http.Request) {
// Now delete // Now delete
err = s.db.DeleteTodo(todoID) err = s.db.DeleteTodo(todoID)
if err != nil { if err != nil {
logger.Error("[Server] Failed to delete %s's TODO: %s", GetLoginFromAuth(req), err) logger.Error("[Server] Failed to delete %s's TODO: %s", GetLoginFromReq(req), err)
http.Error(w, "Failed to delete TODO", http.StatusInternalServerError) http.Error(w, "Failed to delete TODO", http.StatusInternalServerError)
return return
} }
@ -310,7 +335,7 @@ func (s *Server) EndpointTodoCreate(w http.ResponseWriter, req *http.Request) {
} }
// Add TODO to the database // Add TODO to the database
newTodo.OwnerLogin = GetLoginFromAuth(req) newTodo.OwnerLogin = GetLoginFromReq(req)
newTodo.TimeCreatedUnix = uint64(time.Now().Unix()) newTodo.TimeCreatedUnix = uint64(time.Now().Unix())
err = s.db.CreateTodo(newTodo) err = s.db.CreateTodo(newTodo)
if err != nil { if err != nil {
@ -342,7 +367,7 @@ func (s *Server) EndpointUserTodosGet(w http.ResponseWriter, req *http.Request)
} }
// Get all user TODOs // Get all user TODOs
todos, err := s.db.GetAllUserTodos(GetLoginFromAuth(req)) todos, err := s.db.GetAllUserTodos(GetLoginFromReq(req))
if err != nil { if err != nil {
http.Error(w, "Failed to get TODOs", http.StatusInternalServerError) http.Error(w, "Failed to get TODOs", http.StatusInternalServerError)
return return
@ -387,7 +412,7 @@ func (s *Server) EndpointTodoGroupDelete(w http.ResponseWriter, req *http.Reques
return return
} }
if !s.db.DoesUserOwnGroup(group.ID, GetLoginFromAuth(req)) { if !s.db.DoesUserOwnGroup(group.ID, GetLoginFromReq(req)) {
http.Error(w, "You don't own this group", http.StatusForbidden) http.Error(w, "You don't own this group", http.StatusForbidden)
return return
} }
@ -395,7 +420,7 @@ func (s *Server) EndpointTodoGroupDelete(w http.ResponseWriter, req *http.Reques
// Now delete // Now delete
err = s.db.DeleteTodoGroup(group.ID) err = s.db.DeleteTodoGroup(group.ID)
if err != nil { if err != nil {
logger.Error("[Server] Failed to delete %s's TODO group: %s", GetLoginFromAuth(req), err) logger.Error("[Server] Failed to delete %s's TODO group: %s", GetLoginFromReq(req), err)
http.Error(w, "Failed to delete TODO group", http.StatusInternalServerError) http.Error(w, "Failed to delete TODO group", http.StatusInternalServerError)
return return
} }
@ -431,7 +456,7 @@ func (s *Server) EndpointTodoGroupCreate(w http.ResponseWriter, req *http.Reques
} }
// Add group to the database // Add group to the database
newGroup.OwnerLogin = GetLoginFromAuth(req) newGroup.OwnerLogin = GetLoginFromReq(req)
newGroup.TimeCreatedUnix = uint64(time.Now().Unix()) newGroup.TimeCreatedUnix = uint64(time.Now().Unix())
err = s.db.CreateTodoGroup(newGroup) err = s.db.CreateTodoGroup(newGroup)
if err != nil { if err != nil {
@ -456,7 +481,7 @@ func (s *Server) EndpointTodoGroupGet(w http.ResponseWriter, req *http.Request)
} }
// Get groups // Get groups
groups, err := s.db.GetAllUserTodoGroups(GetLoginFromAuth(req)) groups, err := s.db.GetAllUserTodoGroups(GetLoginFromReq(req))
if err != nil { if err != nil {
http.Error(w, "Failed to get TODO groups", http.StatusInternalServerError) http.Error(w, "Failed to get TODO groups", http.StatusInternalServerError)
return return

42
src/server/page.go

@ -1,6 +1,6 @@
/* /*
dela - web TODO list dela - web TODO list
Copyright (C) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte) Copyright (C) 2023, 2024 Kasyanov Nikolay Alexeyevich (Unbewohnte)
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by it under the terms of the GNU Affero General Public License as published by
@ -19,6 +19,7 @@
package server package server
import ( import (
"Unbewohnte/dela/db"
"html/template" "html/template"
"path/filepath" "path/filepath"
) )
@ -35,3 +36,42 @@ func getPage(pagesDir string, basePageName string, pageName string) (*template.T
return page, nil return page, nil
} }
type IndexPageData struct {
Groups []*db.TodoGroup `json:"groups"`
}
func GetIndexPageData(db *db.DB, login string) (*IndexPageData, error) {
groups, err := db.GetAllUserTodoGroups(login)
if err != nil {
return nil, err
}
return &IndexPageData{
Groups: groups,
}, nil
}
type CategoryPageData struct {
Groups []*db.TodoGroup `json:"groups"`
CurrentGroupId uint64 `json:"currentGroupId"`
Todos []*db.Todo `json:"todos"`
}
func GetCategoryPageData(db *db.DB, login string, groupId uint64) (*CategoryPageData, error) {
groups, err := db.GetAllUserTodoGroups(login)
if err != nil {
return nil, err
}
todos, err := db.GetGroupTodos(groupId)
if err != nil {
return nil, err
}
return &CategoryPageData{
Groups: groups,
CurrentGroupId: groupId,
Todos: todos,
}, nil
}

79
src/server/server.go

@ -1,6 +1,6 @@
/* /*
dela - web TODO list dela - web TODO list
Copyright (C) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte) Copyright (C) 2023, 2024 Kasyanov Nikolay Alexeyevich (Unbewohnte)
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by it under the terms of the GNU Affero General Public License as published by
@ -25,8 +25,11 @@ import (
"context" "context"
"fmt" "fmt"
"net/http" "net/http"
"net/http/cookiejar"
"os" "os"
"path"
"path/filepath" "path/filepath"
"strconv"
"time" "time"
) )
@ -40,6 +43,7 @@ type Server struct {
config conf.Conf config conf.Conf
db *db.DB db *db.DB
http http.Server http http.Server
cookieJar *cookiejar.Jar
} }
// Creates a new server instance with provided config // Creates a new server instance with provided config
@ -102,19 +106,75 @@ func New(config conf.Conf) (*Server, error) {
// handle page requests // handle page requests
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
switch req.URL.Path { if req.Method != "GET" {
case "/": http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if req.URL.Path == "/" {
// Auth first
if !IsUserAuthorizedReq(req, server.db) {
http.Redirect(w, req, "/about", http.StatusTemporaryRedirect)
return
}
requestedPage, err := getPage( requestedPage, err := getPage(
filepath.Join(server.config.BaseContentDir, PagesDirName), "base.html", "index.html", filepath.Join(server.config.BaseContentDir, PagesDirName), "base.html", "index.html",
) )
if err != nil { if err != nil {
http.Redirect(w, req, "/about", http.StatusTemporaryRedirect) http.Redirect(w, req, "/error", http.StatusTemporaryRedirect)
logger.Error("[Server][/] Failed to get a page: %s", err) logger.Error("[Server][/] Failed to get a page: %s", err)
return return
} }
requestedPage.ExecuteTemplate(w, "index.html", nil) pageData, err := GetIndexPageData(server.db, GetLoginFromReq(req))
default: if err != nil {
http.Redirect(w, req, "/error", http.StatusTemporaryRedirect)
logger.Error("[Server][/] Failed to get index page data: %s", err)
return
}
requestedPage.ExecuteTemplate(w, "index.html", &pageData)
} else if path.Dir(req.URL.Path) == "/group" {
if req.Method != "GET" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Auth first
if !IsUserAuthorizedReq(req, server.db) {
http.Redirect(w, req, "/about", http.StatusTemporaryRedirect)
return
}
// Get group ID
groupId, err := strconv.ParseUint(path.Base(req.URL.Path), 10, 64)
if err != nil {
http.Redirect(w, req, "/error", http.StatusTemporaryRedirect)
return
}
requestedPage, err := getPage(
filepath.Join(server.config.BaseContentDir, PagesDirName), "base.html", "category.html",
)
if err != nil {
http.Redirect(w, req, "/error", http.StatusTemporaryRedirect)
logger.Error("[Server][/category/] Failed to get a page: %s", err)
return
}
// Get page data
pageData, err := GetCategoryPageData(server.db, GetLoginFromReq(req), groupId)
if err != nil {
http.Redirect(w, req, "/error", http.StatusTemporaryRedirect)
logger.Error("[Server][/category/] Failed to get category (%d) page data: %s", groupId, err)
return
}
requestedPage.ExecuteTemplate(w, "category.html", &pageData)
} else {
// default
requestedPage, err := getPage( requestedPage, err := getPage(
filepath.Join(server.config.BaseContentDir, PagesDirName), filepath.Join(server.config.BaseContentDir, PagesDirName),
"base.html", "base.html",
@ -123,7 +183,8 @@ func New(config conf.Conf) (*Server, error) {
if err == nil { if err == nil {
requestedPage.ExecuteTemplate(w, req.URL.Path[1:]+".html", nil) requestedPage.ExecuteTemplate(w, req.URL.Path[1:]+".html", nil)
} else { } else {
http.Error(w, "Page processing error", http.StatusInternalServerError) // http.Error(w, "Page processing error", http.StatusInternalServerError)
http.Redirect(w, req, "/error", http.StatusTemporaryRedirect)
} }
} }
}) })
@ -131,6 +192,7 @@ func New(config conf.Conf) (*Server, error) {
mux.HandleFunc("/api/user/delete", server.EndpointUserDelete) // Non specific mux.HandleFunc("/api/user/delete", server.EndpointUserDelete) // Non specific
mux.HandleFunc("/api/user/update", server.EndpointUserUpdate) // Non specific mux.HandleFunc("/api/user/update", server.EndpointUserUpdate) // Non specific
mux.HandleFunc("/api/user/create", server.EndpointUserCreate) // Non specific mux.HandleFunc("/api/user/create", server.EndpointUserCreate) // Non specific
mux.HandleFunc("/api/todo/create", server.EndpointTodoCreate) // Non specific
mux.HandleFunc("/api/todo/get", server.EndpointUserTodosGet) // Non specific mux.HandleFunc("/api/todo/get", server.EndpointUserTodosGet) // Non specific
mux.HandleFunc("/api/todo/delete/", server.EndpointTodoDelete) // Specific mux.HandleFunc("/api/todo/delete/", server.EndpointTodoDelete) // Specific
mux.HandleFunc("/api/todo/update/", server.EndpointTodoUpdate) // Specific mux.HandleFunc("/api/todo/update/", server.EndpointTodoUpdate) // Specific
@ -140,6 +202,9 @@ func New(config conf.Conf) (*Server, error) {
mux.HandleFunc("/api/group/delete/", server.EndpointTodoGroupDelete) // Specific mux.HandleFunc("/api/group/delete/", server.EndpointTodoGroupDelete) // Specific
server.http.Handler = mux server.http.Handler = mux
jar, _ := cookiejar.New(nil)
server.cookieJar = jar
logger.Info("[Server] Created an HTTP server instance") logger.Info("[Server] Created an HTTP server instance")
return &server, nil return &server, nil

49
src/server/validation.go

@ -1,6 +1,6 @@
/* /*
dela - web TODO list dela - web TODO list
Copyright (C) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte) Copyright (C) 2023, 2024 Kasyanov Nikolay Alexeyevich (Unbewohnte)
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by it under the terms of the GNU Affero General Public License as published by
@ -21,6 +21,7 @@ package server
import ( import (
"Unbewohnte/dela/db" "Unbewohnte/dela/db"
"net/http" "net/http"
"strings"
) )
const ( const (
@ -34,11 +35,16 @@ func IsUserValid(user db.User) (bool, string) {
if uint(len(user.Login)) < MinimalLoginLength { if uint(len(user.Login)) < MinimalLoginLength {
return false, "Login is too small" return false, "Login is too small"
} }
for _, char := range user.Login {
if char < 0x21 || char > 0x7E {
// Not printable ASCII char!
return false, "Login has a non printable ASCII character"
}
}
if uint(len(user.Password)) < MinimalPasswordLength { if uint(len(user.Password)) < MinimalPasswordLength {
return false, "Password is too small" return false, "Password is too small"
} }
for _, char := range user.Password { for _, char := range user.Password {
if char < 0x21 || char > 0x7E { if char < 0x21 || char > 0x7E {
// Not printable ASCII char! // Not printable ASCII char!
@ -63,24 +69,55 @@ func IsUserAuthorized(db *db.DB, user db.User) bool {
return true return true
} }
// Returns login and password from a cookie. If an error is encountered, returns empty strings
func AuthFromCookie(cookie *http.Cookie) (string, string) {
if cookie == nil {
return "", ""
}
parts := strings.Split(cookie.Value, ":")
if len(parts) != 2 {
return "", ""
}
return parts[0], parts[1]
}
/* /*
Gets auth information from a request and Gets auth information from a request and
checks if such a user exists and compares passwords. checks if such a user exists and compares passwords.
Returns true if such user exists and passwords do match Returns true if such user exists and passwords do match
*/ */
func IsUserAuthorizedReq(req *http.Request, dbase *db.DB) bool { func IsUserAuthorizedReq(req *http.Request, dbase *db.DB) bool {
login, password, ok := req.BasicAuth() var login, password string
if !ok { var ok bool
login, password, ok = req.BasicAuth()
if !ok || login == "" || password == "" {
cookie, err := req.Cookie("auth")
if err != nil {
return false return false
} }
login, password = AuthFromCookie(cookie)
}
return IsUserAuthorized(dbase, db.User{ return IsUserAuthorized(dbase, db.User{
Login: login, Login: login,
Password: password, Password: password,
}) })
} }
func GetLoginFromAuth(req *http.Request) string { // Returns login value from basic auth or from cookie if the former does not exist
login, _, _ := req.BasicAuth() func GetLoginFromReq(req *http.Request) string {
login, _, ok := req.BasicAuth()
if !ok || login == "" {
cookie, err := req.Cookie("auth")
if err != nil {
return ""
}
login, _ = AuthFromCookie(cookie)
}
return login return login
} }

4
static/images/box-arrow-up-right.svg

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-box-arrow-up-right" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5"/>
<path fill-rule="evenodd" d="M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0z"/>
</svg>

After

Width:  |  Height:  |  Size: 528 B

Loading…
Cancel
Save