Browse Source

Feature: Auth via Cookies; Todo creation

master
parent
commit
3d7c13e704
  1. 2
      .gitignore
  2. 0
      COPYING
  3. 95
      pages/base.html
  4. 111
      pages/category.html
  5. 12
      pages/error.html
  6. 354
      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. 11
      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. 85
      src/server/server.go
  18. 51
      src/server/validation.go
  19. 4
      static/images/box-arrow-up-right.svg

2
.gitignore vendored

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

95
pages/base.html

@ -12,47 +12,31 @@
<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="p-3 text-bg-primary">
<!-- <header class="d-flex flex-wrap align-items-center justify-content-center py-3 mb-4 border-bottom"> <div class="container">
<div class="col-md-3 mb-2 mb-md-0"> <div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
<a href="/" class="d-flex align-items-center mb-2 mb-lg-0 text-white text-decoration-none">
<a href="/" class="d-inline-flex link-body-emphasis text-decoration-none"> <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"> <img width="64" height="64" src="/static/images/android-chrome-192x192.png" alt="Dela">
</a> </a>
</a>
<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
<li><a href="/register" class="nav-link px-2 text-white">Register</a></li>
<li><a href="/about" class="nav-link px-2 text-white">About</a></li>
</ul>
<div class="text-end" id="bar-auth">
<a href="/login" class="btn btn-outline-light me-2">Login</a>
<a href="/register" class="btn btn-warning">Sign-up</a>
</div> </div>
</div>
</div>
</header>
<div class="col-md-3 text-end" id="bar-auth"> <!-- Content -->
<a href="/login" class="btn btn-outline-primary"><img src="/static/images/door-open-fill.svg" alt="Log in"></a> {{ template "content" . }}
<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">
<div class="container">
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
<a href="/" class="d-flex align-items-center mb-2 mb-lg-0 text-white text-decoration-none">
<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>
</a>
<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
<li><a href="/register" class="nav-link px-2 text-white">Register</a></li>
<li><a href="/about" class="nav-link px-2 text-white">About</a></li>
</ul>
<div class="text-end" id="bar-auth">
<a href="/login" class="btn btn-outline-light me-2">Login</a>
<a href="/register" class="btn btn-warning">Sign-up</a>
</div>
</div>
</div>
</header>
<!-- Content -->
{{ template "content" . }}
</body> </body>
</html> </html>
@ -61,31 +45,24 @@
<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") {
window.location.replace("/about");
if (login == null | login == "" | password == null | password == "") {
if (window.location.pathname != "/about" && window.location.pathname != "/login" && window.location.pathname != "/register") {
window.location.replace("/about");
}
return;
}
// Check if auth info is indeed valid
let response = await getUser(login, password);
if (response.ok) {
let barAuth = document.getElementById("bar-auth");
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>';
document.getElementById("log-out-btn").addEventListener("click", (event) => {
// Log out
forgetAuthInfo();
window.location.replace("/about");
});
} else {
forgetAuthInfo();
window.location.replace("/about");
} }
return;
}
// Check if auth info is indeed valid
let response = await getUser();
if (response.ok) {
let barAuth = document.getElementById("bar-auth");
// 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>';
document.getElementById("log-out-btn").addEventListener("click", (event) => {
// Log out
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 }}

354
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>
<div class="list-group list-group-flush border-bottom scrollarea"> {{ range .Groups }}
<a href="#" class="list-group-item list-group-item-action active py-3 lh-sm" aria-current="true"> <div class="list-group list-group-flush border-bottom scrollarea">
<div class="d-flex w-100 align-items-center justify-content-between"> <a href="/group/{{.ID}}" class="list-group-item list-group-item-action active py-3 lh-sm" aria-current="true">
<strong class="mb-1">List group item heading</strong> <div class="d-flex w-100 align-items-center justify-content-between">
<small>Wed</small> <strong class="mb-1">{{ .Name }}</strong>
</div> <small>{{ .TimeCreatedUnix }}</small>
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div> </div>
</a> <div class="col-10 mb-1 small">Is removable: {{ .Removable }}</div>
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm"> </a>
<div class="d-flex w-100 align-items-center justify-content-between"> </div>
<strong class="mb-1">List group item heading</strong> {{ end }}
<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> </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> </div>
<button class="btn btn-secondary" id="show-done">Show Done</button> <small class="opacity-50 text-nowrap">{{ .TimeCreatedUnix }}</small>
</div>
</div>
</div> </div>
</form> </a>
{{ end }}
<div class="container text-center">
<table id="todos" class="table table-hover" style="word-wrap: break-word;">
</table>
</div> </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";
}
// 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" // // Make it "reset to default"
showDoneButton.addEventListener("click", (event) => { // showDoneButton.addEventListener("click", (event) => {
location.reload(); // location.reload();
}); // });
}); // });
// "Add" button // // "Add" button
document.getElementById("new-todo-submit").addEventListener("click", async (event) => { // document.getElementById("new-todo-submit").addEventListener("click", async (event) => {
let newTodoTextInput = document.getElementById("new-todo-text"); // let newTodoTextInput = document.getElementById("new-todo-text");
let newTodoText = newTodoTextInput.value; // let newTodoText = newTodoTextInput.value;
if (newTodoText.length < 1) { // if (newTodoText.length < 1) {
newTodoTextInput.setCustomValidity("At least one character is needed!"); // newTodoTextInput.setCustomValidity("At least one character is needed!");
return; // return;
} else { // } else {
newTodoTextInput.setCustomValidity(""); // newTodoTextInput.setCustomValidity("");
} // }
newTodoTextInput.value = ""; // newTodoTextInput.value = "";
// Make a request // // Make a request
let response = await postNewTodo(username, password, {text: newTodoText}); // let response = await postNewTodo({text: newTodoText, groupId: groupId});
if (response.ok) { // if (response.ok) {
location.reload(); // location.reload();
} // }
}); // });
// Fetch and display TODOs // // Fetch and display TODOs
await displayTodos(false); // await displayTodos(false);
}, 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}
* *

11
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
@ -33,10 +33,10 @@ type DB struct {
func setUpTables(db *DB) error { func setUpTables(db *DB) error {
// Users // Users
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS users( _, err := db.Exec(`CREATE TABLE IF NOT EXISTS users(
login TEXT PRIMARY KEY UNIQUE, login TEXT PRIMARY KEY UNIQUE,
email TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL, password TEXT NOT NULL,
time_created_unix INTEGER)`, time_created_unix INTEGER)`,
) )
if err != nil { if err != nil {
return err return err
@ -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
}

85
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"
) )
@ -37,9 +40,10 @@ const (
) )
type Server struct { 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

51
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,15 +69,36 @@ 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
return false login, password, ok = req.BasicAuth()
if !ok || login == "" || password == "" {
cookie, err := req.Cookie("auth")
if err != nil {
return false
}
login, password = AuthFromCookie(cookie)
} }
return IsUserAuthorized(dbase, db.User{ return IsUserAuthorized(dbase, db.User{
@ -80,7 +107,17 @@ func IsUserAuthorizedReq(req *http.Request, dbase *db.DB) bool {
}) })
} }
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