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>
</head>
<!-- <body class="d-flex flex-column h-100" style="width: 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">
<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>
<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">
<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" . }}
<!-- Content -->
{{ template "content" . }}
</body>
</html>
@ -61,31 +45,24 @@
<script src="/scripts/api.js"></script>
<script>
document.addEventListener('DOMContentLoaded', async function() {
let login = getLogin();
let password = getUserPassword();
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");
if (document.cookie.indexOf("auth=") == -1) {
if (window.location.pathname != "/about" && window.location.pathname != "/login" && window.location.pathname != "/register" && window.location.pathname != "/error") {
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)
</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;">
<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>
<span class="fs-5 fw-semibold">Categories</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>
{{ 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">
<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 class="d-flex flex-column flex-md-row p-4 gap-4 py-md-5 align-items-center justify-content-center">
<div class="list-group">
{{ range .Groups }}
<a href="/group/{{.ID}}" class="list-group-item list-group-item-action d-flex gap-3 py-3" aria-current="true">
<img src="/static/images/box-arrow-up-right.svg" alt="Go to this category" width="32" height="32">
<div class="d-flex gap-2 w-100 justify-content-between">
<div>
<h6 class="mb-0">{{ .Name }}</h6>
<p class="mb-0 opacity-75">Jump here</p>
</div>
<small class="opacity-50 text-nowrap">{{ .TimeCreatedUnix }}</small>
</div>
</form>
<div class="container text-center">
<table id="todos" class="table table-hover" style="word-wrap: break-word;">
</table>
</a>
{{ end }}
</div>
</div>
</main>
<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";
// function todoBlock(todo, editable) {
// let todoCompleteBtnID = "btn-complete-" + String(todo.id);
// let todoDeleteBtnID = "btn-delete-" + String(todo.id);
// let todoEditBtnID = "btn-edit-" + String(todo.id);
// // Display
// let timeCreated = new Date(todo.timeCreatedUnix * 1000);
// return "<tr><td>" + todo.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>";
// }
// async function displayTodos(showDone) {
// // Fetch and display TODOs
// let response = await getTodos();
// if (!response.ok) {
// // window.location.replace("/error")
// return;
// }
// let todosJson = await response.json();
// if (todosJson == null) {
// return;
// }
// let todosDisplayed = [];
// let todosDiv = document.getElementById("todos");
// // Clear what we've had before
// todosDiv.innerHTML = "";
// todosJson.forEach((item) => {
// let todoBlk = todoBlock(item, item.isDone);
// todosDiv.innerHTML += todoBlk;
// });
// }
// document.addEventListener('DOMContentLoaded', async function() {
// 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)
// // 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({text: newTodoText, groupId: groupId});
// if (response.ok) {
// location.reload();
// }
// });
// // Fetch and display TODOs
// await displayTodos(false);
// }, false)
</script>
{{ end }}

4
pages/login.html

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

1
pages/register.html

@ -59,7 +59,6 @@ async function register() {
let response = await postNewUser(postData);
if (response.ok) {
rememberAuthInfo(postData.login, postData.password);
window.location.replace("/");
} else {
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, login, password, json) {
async function post(url, json) {
return fetch(url, {
method: "POST",
credentials: "include",
headers: {
"Authorization": "Basic " + btoa(login + ":" + password),
"Content-Type": "application/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) {
return post("/api/group/create", login, password, newGroup)
async function postNewGroup(newGroup) {
return post("/api/group/create", newGroup)
}
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, {
method: "GET",
credentials: "include",
headers: {
"Authorization": "Basic " + btoa(login + ":" + password),
"Content-Type": "application/json",
},
})
}
async function getUser(login, password) {
return get("/api/user/get", login, password);
async function getUser() {
return get("/api/user/get");
}
async function getTodos(login, password) {
return get("/api/todo/get", login, password);
async function getTodos() {
return get("/api/todo/get");
}
async function getGroup(login, password) {
return get("/api/group/get", login, password);
async function getGroup() {
return get("/api/group/get");
}
async function getAllGroups(login, password) {
return get("/api/user/get", login, password);
async function getAllGroups() {
return get("/api/user/get");
}
async function del(url, login, password) {
async function del(url) {
return fetch(url, {
method: "DELETE",
credentials: "include",
headers: {
"Authorization": "Basic " + btoa(login + ":" + password),
"Content-Type": "application/json",
},
})
}
async function deleteTodo(login, password, id) {
return del("/api/todo/delete/"+id, login, password);
async function deleteTodo(id) {
return del("/api/todo/delete/"+id);
}
async function update(url, login, password, json) {
return post(url, login, password, json);
async function update(url, json) {
return post(url, json);
}
async function updateTodo(login, password, id, updatedTodo) {
return update("/api/todo/update/"+id, login, password, updatedTodo);
async function updateTodo(id, updatedTodo) {
return update("/api/todo/update/"+id, updatedTodo);
}
async function updateGroup(login, password, id, updatedGroup) {
return update("/api/group/update/"+id, login, password, updateGroup);
async function updateGroup(id, updatedGroup) {
return update("/api/group/update/"+id, updatedGroup);
}
async function updateUser(login, password, updatedUser) {
return update("/api/group/update/"+login, login, password, updatedUser);
async function updateUser(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}
*

11
src/db/db.go

@ -1,6 +1,6 @@
/*
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
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 {
// Users
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS users(
login TEXT PRIMARY KEY UNIQUE,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
time_created_unix INTEGER)`,
login TEXT PRIMARY KEY UNIQUE,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
time_created_unix INTEGER)`,
)
if err != nil {
return err
@ -48,6 +48,7 @@ func setUpTables(db *DB) error {
name TEXT,
time_created_unix INTEGER,
owner_login TEXT NOT NULL,
removable INTEGER,
FOREIGN KEY(owner_login) REFERENCES users(login))`,
)
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
import "database/sql"
@ -8,15 +26,26 @@ type TodoGroup struct {
Name string `json:"name"`
TimeCreatedUnix uint64 `json:"timeCreatedUnix"`
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
func (db *DB) CreateTodoGroup(group TodoGroup) error {
_, 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.TimeCreatedUnix,
group.OwnerLogin,
group.Removable,
)
return err
@ -29,6 +58,7 @@ func scanTodoGroup(rows *sql.Rows) (*TodoGroup, error) {
&newTodoGroup.Name,
&newTodoGroup.TimeCreatedUnix,
&newTodoGroup.OwnerLogin,
&newTodoGroup.Removable,
)
if err != nil {
return nil, err
@ -78,6 +108,26 @@ func (db *DB) GetTodoGroups() ([]*TodoGroup, error) {
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
func (db *DB) DeleteTodoGroup(id uint64) error {
_, err := db.Exec(

28
src/db/todo.go

@ -1,6 +1,6 @@
/*
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
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
func (db *DB) CreateTodo(todo Todo) error {
_, 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.Text,
todo.TimeCreatedUnix,
@ -134,12 +134,12 @@ func (db *DB) UpdateTodo(todoID uint64, updatedTodo Todo) error {
}
// 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
rows, err := db.Query(
"SELECT * FROM todo_groups WHERE owner_username=?",
username,
"SELECT * FROM todo_groups WHERE owner_login=?",
login,
)
if err != nil {
return nil, err
@ -158,12 +158,12 @@ func (db *DB) GetAllUserTodoGroups(username string) ([]*TodoGroup, error) {
}
// 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
rows, err := db.Query(
"SELECT * FROM todos WHERE owner_username=?",
username,
"SELECT * FROM todos WHERE owner_login=?",
login,
)
if err != nil {
return nil, err
@ -183,20 +183,20 @@ func (db *DB) GetAllUserTodos(username string) ([]*Todo, error) {
}
// 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(
"DELETE FROM todos WHERE owner_username=?",
username,
"DELETE FROM todos WHERE owner_login=?",
login,
)
return err
}
// 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(
"DELETE FROM todo_groups WHERE owner_username=?",
username,
"DELETE FROM todo_groups WHERE owner_login=?",
login,
)
return err

51
src/server/endpoints.go

@ -1,6 +1,6 @@
/*
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
it under the terms of the GNU Affero General Public License as published by
@ -22,6 +22,7 @@ import (
"Unbewohnte/dela/db"
"Unbewohnte/dela/logger"
"encoding/json"
"fmt"
"io"
"net/http"
"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)
// 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)
}
func (s *Server) EndpointUserUpdate(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 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
login, _, _ := req.BasicAuth()
login := GetLoginFromReq(req)
if login != user.Login {
// Gotcha!
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
login, _, _ := req.BasicAuth()
login := GetLoginFromReq(req)
err := s.db.DeleteUser(login)
if err != nil {
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
login, _, _ := req.BasicAuth()
login := GetLoginFromReq(req)
userDB, err := s.db.GetUser(login)
if err != nil {
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
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)
return
}
@ -265,7 +290,7 @@ func (s *Server) EndpointTodoDelete(w http.ResponseWriter, req *http.Request) {
}
// 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)
return
}
@ -273,7 +298,7 @@ func (s *Server) EndpointTodoDelete(w http.ResponseWriter, req *http.Request) {
// Now delete
err = s.db.DeleteTodo(todoID)
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)
return
}
@ -310,7 +335,7 @@ func (s *Server) EndpointTodoCreate(w http.ResponseWriter, req *http.Request) {
}
// Add TODO to the database
newTodo.OwnerLogin = GetLoginFromAuth(req)
newTodo.OwnerLogin = GetLoginFromReq(req)
newTodo.TimeCreatedUnix = uint64(time.Now().Unix())
err = s.db.CreateTodo(newTodo)
if err != nil {
@ -342,7 +367,7 @@ func (s *Server) EndpointUserTodosGet(w http.ResponseWriter, req *http.Request)
}
// Get all user TODOs
todos, err := s.db.GetAllUserTodos(GetLoginFromAuth(req))
todos, err := s.db.GetAllUserTodos(GetLoginFromReq(req))
if err != nil {
http.Error(w, "Failed to get TODOs", http.StatusInternalServerError)
return
@ -387,7 +412,7 @@ func (s *Server) EndpointTodoGroupDelete(w http.ResponseWriter, req *http.Reques
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)
return
}
@ -395,7 +420,7 @@ func (s *Server) EndpointTodoGroupDelete(w http.ResponseWriter, req *http.Reques
// Now delete
err = s.db.DeleteTodoGroup(group.ID)
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)
return
}
@ -431,7 +456,7 @@ func (s *Server) EndpointTodoGroupCreate(w http.ResponseWriter, req *http.Reques
}
// Add group to the database
newGroup.OwnerLogin = GetLoginFromAuth(req)
newGroup.OwnerLogin = GetLoginFromReq(req)
newGroup.TimeCreatedUnix = uint64(time.Now().Unix())
err = s.db.CreateTodoGroup(newGroup)
if err != nil {
@ -456,7 +481,7 @@ func (s *Server) EndpointTodoGroupGet(w http.ResponseWriter, req *http.Request)
}
// Get groups
groups, err := s.db.GetAllUserTodoGroups(GetLoginFromAuth(req))
groups, err := s.db.GetAllUserTodoGroups(GetLoginFromReq(req))
if err != nil {
http.Error(w, "Failed to get TODO groups", http.StatusInternalServerError)
return

42
src/server/page.go

@ -1,6 +1,6 @@
/*
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
it under the terms of the GNU Affero General Public License as published by
@ -19,6 +19,7 @@
package server
import (
"Unbewohnte/dela/db"
"html/template"
"path/filepath"
)
@ -35,3 +36,42 @@ func getPage(pagesDir string, basePageName string, pageName string) (*template.T
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
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
it under the terms of the GNU Affero General Public License as published by
@ -25,8 +25,11 @@ import (
"context"
"fmt"
"net/http"
"net/http/cookiejar"
"os"
"path"
"path/filepath"
"strconv"
"time"
)
@ -37,9 +40,10 @@ const (
)
type Server struct {
config conf.Conf
db *db.DB
http http.Server
config conf.Conf
db *db.DB
http http.Server
cookieJar *cookiejar.Jar
}
// Creates a new server instance with provided config
@ -102,19 +106,75 @@ func New(config conf.Conf) (*Server, error) {
// handle page requests
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
switch req.URL.Path {
case "/":
if req.Method != "GET" {
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(
filepath.Join(server.config.BaseContentDir, PagesDirName), "base.html", "index.html",
)
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)
return
}
requestedPage.ExecuteTemplate(w, "index.html", nil)
default:
pageData, err := GetIndexPageData(server.db, GetLoginFromReq(req))
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(
filepath.Join(server.config.BaseContentDir, PagesDirName),
"base.html",
@ -123,7 +183,8 @@ func New(config conf.Conf) (*Server, error) {
if err == nil {
requestedPage.ExecuteTemplate(w, req.URL.Path[1:]+".html", nil)
} 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/update", server.EndpointUserUpdate) // 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/delete/", server.EndpointTodoDelete) // 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
server.http.Handler = mux
jar, _ := cookiejar.New(nil)
server.cookieJar = jar
logger.Info("[Server] Created an HTTP server instance")
return &server, nil

51
src/server/validation.go

@ -1,6 +1,6 @@
/*
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
it under the terms of the GNU Affero General Public License as published by
@ -21,6 +21,7 @@ package server
import (
"Unbewohnte/dela/db"
"net/http"
"strings"
)
const (
@ -34,11 +35,16 @@ func IsUserValid(user db.User) (bool, string) {
if uint(len(user.Login)) < MinimalLoginLength {
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 {
return false, "Password is too small"
}
for _, char := range user.Password {
if char < 0x21 || char > 0x7E {
// Not printable ASCII char!
@ -63,15 +69,36 @@ func IsUserAuthorized(db *db.DB, user db.User) bool {
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
checks if such a user exists and compares passwords.
Returns true if such user exists and passwords do match
*/
func IsUserAuthorizedReq(req *http.Request, dbase *db.DB) bool {
login, password, ok := req.BasicAuth()
if !ok {
return false
var login, password string
var ok bool
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{
@ -80,7 +107,17 @@ func IsUserAuthorizedReq(req *http.Request, dbase *db.DB) bool {
})
}
func GetLoginFromAuth(req *http.Request) string {
login, _, _ := req.BasicAuth()
// Returns login value from basic auth or from cookie if the former does not exist
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
}

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