Browse Source

Initial Dela Web App commit

master
parent
commit
0a175dbe81
  1. 47
      Makefile
  2. 27
      README.md
  3. 9
      TODO
  4. 15
      pages/about.html
  5. 92
      pages/base.html
  6. 270
      pages/index.html
  7. 58
      pages/login.html
  8. 69
      pages/register.html
  9. 270
      pages/todos.html
  10. 83
      scripts/api.js
  11. 545
      scripts/auth.js
  12. 87
      src/conf/conf.go
  13. 113
      src/db/db.go
  14. 97
      src/db/db_test.go
  15. 113
      src/db/group.go
  16. 216
      src/db/todo.go
  17. 110
      src/db/user.go
  18. 43
      src/encryption/encryption.go
  19. 26
      src/go.mod
  20. 65
      src/go.sum
  21. 60
      src/logger/logger.go
  22. 96
      src/main.go
  23. 146
      src/server/api_test.go
  24. 510
      src/server/endpoints.go
  25. 37
      src/server/page.go
  26. 178
      src/server/server.go
  27. 86
      src/server/validation.go
  28. 4085
      static/bootstrap/css/bootstrap-grid.css
  29. 1
      static/bootstrap/css/bootstrap-grid.css.map
  30. 6
      static/bootstrap/css/bootstrap-grid.min.css
  31. 1
      static/bootstrap/css/bootstrap-grid.min.css.map
  32. 4084
      static/bootstrap/css/bootstrap-grid.rtl.css
  33. 1
      static/bootstrap/css/bootstrap-grid.rtl.css.map
  34. 6
      static/bootstrap/css/bootstrap-grid.rtl.min.css
  35. 1
      static/bootstrap/css/bootstrap-grid.rtl.min.css.map
  36. 593
      static/bootstrap/css/bootstrap-reboot.css
  37. 1
      static/bootstrap/css/bootstrap-reboot.css.map
  38. 6
      static/bootstrap/css/bootstrap-reboot.min.css
  39. 1
      static/bootstrap/css/bootstrap-reboot.min.css.map
  40. 590
      static/bootstrap/css/bootstrap-reboot.rtl.css
  41. 1
      static/bootstrap/css/bootstrap-reboot.rtl.css.map
  42. 6
      static/bootstrap/css/bootstrap-reboot.rtl.min.css
  43. 1
      static/bootstrap/css/bootstrap-reboot.rtl.min.css.map
  44. 5399
      static/bootstrap/css/bootstrap-utilities.css
  45. 1
      static/bootstrap/css/bootstrap-utilities.css.map
  46. 6
      static/bootstrap/css/bootstrap-utilities.min.css
  47. 1
      static/bootstrap/css/bootstrap-utilities.min.css.map
  48. 5390
      static/bootstrap/css/bootstrap-utilities.rtl.css
  49. 1
      static/bootstrap/css/bootstrap-utilities.rtl.css.map
  50. 6
      static/bootstrap/css/bootstrap-utilities.rtl.min.css
  51. 1
      static/bootstrap/css/bootstrap-utilities.rtl.min.css.map
  52. 12071
      static/bootstrap/css/bootstrap.css
  53. 1
      static/bootstrap/css/bootstrap.css.map
  54. 6
      static/bootstrap/css/bootstrap.min.css
  55. 1
      static/bootstrap/css/bootstrap.min.css.map
  56. 12035
      static/bootstrap/css/bootstrap.rtl.css
  57. 1
      static/bootstrap/css/bootstrap.rtl.css.map
  58. 6
      static/bootstrap/css/bootstrap.rtl.min.css
  59. 1
      static/bootstrap/css/bootstrap.rtl.min.css.map
  60. 6306
      static/bootstrap/js/bootstrap.bundle.js
  61. 1
      static/bootstrap/js/bootstrap.bundle.js.map
  62. 7
      static/bootstrap/js/bootstrap.bundle.min.js
  63. 1
      static/bootstrap/js/bootstrap.bundle.min.js.map
  64. 4439
      static/bootstrap/js/bootstrap.esm.js
  65. 1
      static/bootstrap/js/bootstrap.esm.js.map
  66. 7
      static/bootstrap/js/bootstrap.esm.min.js
  67. 1
      static/bootstrap/js/bootstrap.esm.min.js.map
  68. 4486
      static/bootstrap/js/bootstrap.js
  69. 1
      static/bootstrap/js/bootstrap.js.map
  70. 7
      static/bootstrap/js/bootstrap.min.js
  71. 1
      static/bootstrap/js/bootstrap.min.js.map
  72. BIN
      static/favicon.ico
  73. BIN
      static/images/android-chrome-192x192.png
  74. BIN
      static/images/android-chrome-512x512.png
  75. BIN
      static/images/apple-touch-icon.png
  76. 3
      static/images/check.svg
  77. 3
      static/images/door-open-fill.svg
  78. BIN
      static/images/favicon-16x16.png
  79. BIN
      static/images/favicon-32x32.png
  80. BIN
      static/images/favicon.ico
  81. 4
      static/images/person-dash-fill.svg
  82. 4
      static/images/person-fill-add.svg
  83. 3
      static/images/trash3-fill.svg
  84. 3
      static/images/x.svg

47
Makefile

@ -0,0 +1,47 @@
all: clean
mkdir -p bin && \
cd src && CGO_ENABLED=0 go build && mv dela ../bin && \
cd .. && \
cp -r pages bin && \
cp -r scripts bin && \
cp -r static bin
portable: clean all
cd bin/ && cp ../COPYING . && cp ../README.md . && zip -r dela.zip * && mv dela.zip ..
cross: clean
mkdir -p bin
mkdir -p bin/dela_linux_x64
mkdir -p bin/dela_linux_x32
mkdir -p bin/dela_windows_x64
mkdir -p bin/dela_windows_arm64
mkdir -p bin/dela_darwin_x64
mkdir -p bin/dela_darwin_arm64
mkdir -p bin/dela_freebsd_x64
mkdir -p bin/dela_freebsd_arm64
cd src && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build && mv dela ../bin/dela_linux_x64
cd src && CGO_ENABLED=0 GOOS=linux GOARCH=386 go build && mv dela ../bin/dela_linux_x32
cd src && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build && mv dela.exe ../bin/dela_windows_x64
cd src && CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build && mv dela.exe ../bin/dela_windows_arm64
cd src && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build && mv dela ../bin/dela_darwin_x64
cd src && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build && mv dela ../bin/dela_darwin_arm64
cd src && CGO_ENABLED=0 GOOS=openbsd GOARCH=amd64 go build && mv dela ../bin/dela_freebsd_x64
cd src && CGO_ENABLED=0 GOOS=openbsd GOARCH=arm64 go build && mv dela ../bin/dela_freebsd_arm64
mkdir -p bin/includes
cp -r pages bin/includes
cp -r scripts bin/includes
cp -r static bin/includes
cp COPYING bin/includes
cp LICENSE* bin/includes
cp README.md bin/includes
clean:
rm -rf bin

27
README.md

@ -0,0 +1,27 @@
# Dela dead simple web TODO list
## About
Dela is a web TODO list application which can be hosted on your server and accessed via browser. Current capabilities include:
- Password protected account system
- TODO creation
- TODO completion
## Build
```
make
```
or manually compile with `go build` inside the `src` directory, move the binary and put it alongside `pages`, `scripts` and `static` (although the path to the contents can be specified via configuration file).
```
make portable
```
will create a `dela.zip` archive with all the content directories and the binary which are to be freely transported to other machines (of the same OS and architecture, obviously)
## Use
After the first run a configuration file will be put alongside the executable, upon the second launch server will start and the service will be accessible on the specified port.

9
TODO

@ -0,0 +1,9 @@
1) Профиль
2) Теги заметок/Каталоги заметок
3) Темная/Светлая тема
4) Drag and Drop
5) Подтверждение почты
Не SQLite3
Figma

15
pages/about.html

@ -0,0 +1,15 @@
{{ 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>Dela.</h1>
<p class="lead">Simple and elegant web TODO list</p>
<p class="lead">
<a href="/login" class="btn btn-lg btn-primary">Login</a>
<a href="/register" class="btn btn-lg btn-primary">Register</a>
</p>
</div>
</main>
{{ end }}

92
pages/base.html

@ -0,0 +1,92 @@
{{ define "base" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>dela</title>
<link rel="shortcut icon" href="/static/images/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/static/bootstrap/css/bootstrap.min.css">
<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">
<a href="/" class="d-inline-flex link-body-emphasis text-decoration-none">
<img width="64" height="64" src="/static/images/android-chrome-192x192.png" alt="Dela">
</a>
</div>
<div class="col-md-3 text-end" id="bar-auth">
<a href="/login" class="btn btn-outline-primary"><img src="/static/images/door-open-fill.svg" alt="Log in"></a>
<a href="/register" class="btn btn-outline-primary"><img src="/static/images/person-fill-add.svg" alt="Register"></a>
</div>
</header> -->
<header class="p-3 text-bg-primary">
<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>
</html>
<script src="/scripts/auth.js"></script>
<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");
}
}, false)
</script>
{{ end }}

270
pages/index.html

@ -0,0 +1,270 @@
{{ template "base" . }}
{{ define "content" }}
<!-- 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">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>
<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>
</form>
<div class="container text-center">
<table id="todos" class="table table-hover" style="word-wrap: break-word;">
</table>
</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";
// 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 }}

58
pages/login.html

@ -0,0 +1,58 @@
{{ template "base" . }}
{{ define "content" }}
<main class="d-flex flex-wrap align-content-center align-items-center">
<div class="p-2 flex-fill text-wrap text-center">
<!-- text-center -->
<h3>Log in</h3>
<form name="loginForm" onsubmit="return false;">
<p>
<label for="login" class="form-label">Login</label> <br>
<input type="text" name="login" minlength="3" placeholder="Login" required>
</p>
<p>
<label for="password" class="form-label">Password</label> <br>
<input type="password" name="password" minlength="3" placeholder="Password" required>
</p>
<p><span id="error_message" class="text-danger"></span></p>
<p>
<input type="submit" value="Log in" class="btn btn-primary" onclick="logIn()">
</p>
</form>
</div>
</main>
<script>
async function logIn() {
let loginForm = document.forms["loginForm"];
let login = String(loginForm.elements["login"].value).trim();
if (login.length < 3) {
return;
}
let password = String(loginForm.elements["password"].value);
if (password.length < 3) {
return;
}
password = sha256(password);
// Check if auth info is indeed valid
let response = await getUser(login, password);
if (response.ok) {
rememberAuthInfo(login, password);
window.location.replace("/");
} else {
document.getElementById("error_message").innerText = await response.text();
}
}
</script>
{{ end }}

69
pages/register.html

@ -0,0 +1,69 @@
{{ template "base" . }}
{{ define "content" }}
<main class="d-flex flex-wrap align-content-center">
<div class="p-2 flex-fill text-wrap text-center">
<h3>Register</h3>
<form name="registerForm" onsubmit="return false;">
<p>
<label for="login" class="form-label">Login</label> <br>
<input type="text" name="login" minlength="3" placeholder="Login" required>
</p>
<p>
<label for="email" class="form-label">Email</label> <br>
<input type="email" name="email" minlength="3" placeholder="login@example.com" required>
</p>
<p>
<label for="password" class="form-label">Password</label> <br>
<input type="password" name="password" minlength="3" placeholder="Password" required>
</p>
<p><span id="error_message" class="text-danger"></span></p>
<p>
<input type="submit" value="Register" class="btn btn-primary" onclick="register();">
</p>
</form>
</div>
</main>
<script>
async function register() {
let registerForm = document.forms["registerForm"];
let login = String(registerForm.elements["login"].value).trim();
if (login.length < 3) {
return;
}
let email = String(registerForm.elements["email"].value).trim();
if (email.length < 3) {
return;
}
let password = String(registerForm.elements["password"].value);
if (password.length < 3) {
return;
}
let passwordSHA256 = sha256(password);
let postData = {
login: login,
email: email,
password: passwordSHA256,
};
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();
}
};
</script>
{{ end }}

270
pages/todos.html

@ -0,0 +1,270 @@
{{ 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 }}

83
scripts/api.js

@ -0,0 +1,83 @@
/*
2023 Kasyanov Nikolay Alexeyevich (Unbewohnte)
*/
async function post(url, login, password, json) {
return fetch(url, {
method: "POST",
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 postNewGroup(login, password, newGroup) {
return post("/api/group/create", login, password, newGroup)
}
async function postNewUser(newUser) {
return post("/api/user/create", "", "", newUser)
}
async function get(url, login, password) {
return fetch(url, {
method: "GET",
headers: {
"Authorization": "Basic " + btoa(login + ":" + password),
"Content-Type": "application/json",
},
})
}
async function getUser(login, password) {
return get("/api/user/get", login, password);
}
async function getTodos(login, password) {
return get("/api/todo/get", login, password);
}
async function getGroup(login, password) {
return get("/api/group/get", login, password);
}
async function getAllGroups(login, password) {
return get("/api/user/get", login, password);
}
async function del(url, login, password) {
return fetch(url, {
method: "DELETE",
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 update(url, login, password, json) {
return post(url, login, password, json);
}
async function updateTodo(login, password, id, updatedTodo) {
return update("/api/todo/update/"+id, login, password, updatedTodo);
}
async function updateGroup(login, password, id, updatedGroup) {
return update("/api/group/update/"+id, login, password, updateGroup);
}
async function updateUser(login, password, updatedUser) {
return update("/api/group/update/"+login, login, password, updatedUser);
}

545
scripts/auth.js

@ -0,0 +1,545 @@
/*
2023 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}
*
* @version 0.9.0
* @author Chen, Yi-Cyuan [emn178@gmail.com]
* @copyright Chen, Yi-Cyuan 2014-2017
* @license MIT
*/
/*jslint bitwise: true */
(function () {
'use strict';
var ERROR = 'input is invalid type';
var WINDOW = typeof window === 'object';
var root = WINDOW ? window : {};
if (root.JS_SHA256_NO_WINDOW) {
WINDOW = false;
}
var WEB_WORKER = !WINDOW && typeof self === 'object';
var NODE_JS = !root.JS_SHA256_NO_NODE_JS && typeof process === 'object' && process.versions && process.versions.node;
if (NODE_JS) {
root = global;
} else if (WEB_WORKER) {
root = self;
}
var COMMON_JS = !root.JS_SHA256_NO_COMMON_JS && typeof module === 'object' && module.exports;
var AMD = typeof define === 'function' && define.amd;
var ARRAY_BUFFER = !root.JS_SHA256_NO_ARRAY_BUFFER && typeof ArrayBuffer !== 'undefined';
var HEX_CHARS = '0123456789abcdef'.split('');
var EXTRA = [-2147483648, 8388608, 32768, 128];
var SHIFT = [24, 16, 8, 0];
var K = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
];
var OUTPUT_TYPES = ['hex', 'array', 'digest', 'arrayBuffer'];
var blocks = [];
if (root.JS_SHA256_NO_NODE_JS || !Array.isArray) {
Array.isArray = function (obj) {
return Object.prototype.toString.call(obj) === '[object Array]';
};
}
if (ARRAY_BUFFER && (root.JS_SHA256_NO_ARRAY_BUFFER_IS_VIEW || !ArrayBuffer.isView)) {
ArrayBuffer.isView = function (obj) {
return typeof obj === 'object' && obj.buffer && obj.buffer.constructor === ArrayBuffer;
};
}
var createOutputMethod = function (outputType, is224) {
return function (message) {
return new Sha256(is224, true).update(message)[outputType]();
};
};
var createMethod = function (is224) {
var method = createOutputMethod('hex', is224);
if (NODE_JS) {
method = nodeWrap(method, is224);
}
method.create = function () {
return new Sha256(is224);
};
method.update = function (message) {
return method.create().update(message);
};
for (var i = 0; i < OUTPUT_TYPES.length; ++i) {
var type = OUTPUT_TYPES[i];
method[type] = createOutputMethod(type, is224);
}
return method;
};
var nodeWrap = function (method, is224) {
var crypto = eval("require('crypto')");
var Buffer = eval("require('buffer').Buffer");
var algorithm = is224 ? 'sha224' : 'sha256';
var nodeMethod = function (message) {
if (typeof message === 'string') {
return crypto.createHash(algorithm).update(message, 'utf8').digest('hex');
} else {
if (message === null || message === undefined) {
throw new Error(ERROR);
} else if (message.constructor === ArrayBuffer) {
message = new Uint8Array(message);
}
}
if (Array.isArray(message) || ArrayBuffer.isView(message) ||
message.constructor === Buffer) {
return crypto.createHash(algorithm).update(new Buffer(message)).digest('hex');
} else {
return method(message);
}
};
return nodeMethod;
};
var createHmacOutputMethod = function (outputType, is224) {
return function (key, message) {
return new HmacSha256(key, is224, true).update(message)[outputType]();
};
};
var createHmacMethod = function (is224) {
var method = createHmacOutputMethod('hex', is224);
method.create = function (key) {
return new HmacSha256(key, is224);
};
method.update = function (key, message) {
return method.create(key).update(message);
};
for (var i = 0; i < OUTPUT_TYPES.length; ++i) {
var type = OUTPUT_TYPES[i];
method[type] = createHmacOutputMethod(type, is224);
}
return method;
};
function Sha256(is224, sharedMemory) {
if (sharedMemory) {
blocks[0] = blocks[16] = blocks[1] = blocks[2] = blocks[3] =
blocks[4] = blocks[5] = blocks[6] = blocks[7] =
blocks[8] = blocks[9] = blocks[10] = blocks[11] =
blocks[12] = blocks[13] = blocks[14] = blocks[15] = 0;
this.blocks = blocks;
} else {
this.blocks = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
}
if (is224) {
this.h0 = 0xc1059ed8;
this.h1 = 0x367cd507;
this.h2 = 0x3070dd17;
this.h3 = 0xf70e5939;
this.h4 = 0xffc00b31;
this.h5 = 0x68581511;
this.h6 = 0x64f98fa7;
this.h7 = 0xbefa4fa4;
} else { // 256
this.h0 = 0x6a09e667;
this.h1 = 0xbb67ae85;
this.h2 = 0x3c6ef372;
this.h3 = 0xa54ff53a;
this.h4 = 0x510e527f;
this.h5 = 0x9b05688c;
this.h6 = 0x1f83d9ab;
this.h7 = 0x5be0cd19;
}
this.block = this.start = this.bytes = this.hBytes = 0;
this.finalized = this.hashed = false;
this.first = true;
this.is224 = is224;
}
Sha256.prototype.update = function (message) {
if (this.finalized) {
return;
}
var notString, type = typeof message;
if (type !== 'string') {
if (type === 'object') {
if (message === null) {
throw new Error(ERROR);
} else if (ARRAY_BUFFER && message.constructor === ArrayBuffer) {
message = new Uint8Array(message);
} else if (!Array.isArray(message)) {
if (!ARRAY_BUFFER || !ArrayBuffer.isView(message)) {
throw new Error(ERROR);
}
}
} else {
throw new Error(ERROR);
}
notString = true;
}
var code, index = 0, i, length = message.length, blocks = this.blocks;
while (index < length) {
if (this.hashed) {
this.hashed = false;
blocks[0] = this.block;
blocks[16] = blocks[1] = blocks[2] = blocks[3] =
blocks[4] = blocks[5] = blocks[6] = blocks[7] =
blocks[8] = blocks[9] = blocks[10] = blocks[11] =
blocks[12] = blocks[13] = blocks[14] = blocks[15] = 0;
}
if (notString) {
for (i = this.start; index < length && i < 64; ++index) {
blocks[i >> 2] |= message[index] << SHIFT[i++ & 3];
}
} else {
for (i = this.start; index < length && i < 64; ++index) {
code = message.charCodeAt(index);
if (code < 0x80) {
blocks[i >> 2] |= code << SHIFT[i++ & 3];
} else if (code < 0x800) {
blocks[i >> 2] |= (0xc0 | (code >> 6)) << SHIFT[i++ & 3];
blocks[i >> 2] |= (0x80 | (code & 0x3f)) << SHIFT[i++ & 3];
} else if (code < 0xd800 || code >= 0xe000) {
blocks[i >> 2] |= (0xe0 | (code >> 12)) << SHIFT[i++ & 3];
blocks[i >> 2] |= (0x80 | ((code >> 6) & 0x3f)) << SHIFT[i++ & 3];
blocks[i >> 2] |= (0x80 | (code & 0x3f)) << SHIFT[i++ & 3];
} else {
code = 0x10000 + (((code & 0x3ff) << 10) | (message.charCodeAt(++index) & 0x3ff));
blocks[i >> 2] |= (0xf0 | (code >> 18)) << SHIFT[i++ & 3];
blocks[i >> 2] |= (0x80 | ((code >> 12) & 0x3f)) << SHIFT[i++ & 3];
blocks[i >> 2] |= (0x80 | ((code >> 6) & 0x3f)) << SHIFT[i++ & 3];
blocks[i >> 2] |= (0x80 | (code & 0x3f)) << SHIFT[i++ & 3];
}
}
}
this.lastByteIndex = i;
this.bytes += i - this.start;
if (i >= 64) {
this.block = blocks[16];
this.start = i - 64;
this.hash();
this.hashed = true;
} else {
this.start = i;
}
}
if (this.bytes > 4294967295) {
this.hBytes += this.bytes / 4294967296 << 0;
this.bytes = this.bytes % 4294967296;
}
return this;
};
Sha256.prototype.finalize = function () {
if (this.finalized) {
return;
}
this.finalized = true;
var blocks = this.blocks, i = this.lastByteIndex;
blocks[16] = this.block;
blocks[i >> 2] |= EXTRA[i & 3];
this.block = blocks[16];
if (i >= 56) {
if (!this.hashed) {
this.hash();
}
blocks[0] = this.block;
blocks[16] = blocks[1] = blocks[2] = blocks[3] =
blocks[4] = blocks[5] = blocks[6] = blocks[7] =
blocks[8] = blocks[9] = blocks[10] = blocks[11] =
blocks[12] = blocks[13] = blocks[14] = blocks[15] = 0;
}
blocks[14] = this.hBytes << 3 | this.bytes >>> 29;
blocks[15] = this.bytes << 3;
this.hash();
};
Sha256.prototype.hash = function () {
var a = this.h0, b = this.h1, c = this.h2, d = this.h3, e = this.h4, f = this.h5, g = this.h6,
h = this.h7, blocks = this.blocks, j, s0, s1, maj, t1, t2, ch, ab, da, cd, bc;
for (j = 16; j < 64; ++j) {
// rightrotate
t1 = blocks[j - 15];
s0 = ((t1 >>> 7) | (t1 << 25)) ^ ((t1 >>> 18) | (t1 << 14)) ^ (t1 >>> 3);
t1 = blocks[j - 2];
s1 = ((t1 >>> 17) | (t1 << 15)) ^ ((t1 >>> 19) | (t1 << 13)) ^ (t1 >>> 10);
blocks[j] = blocks[j - 16] + s0 + blocks[j - 7] + s1 << 0;
}
bc = b & c;
for (j = 0; j < 64; j += 4) {
if (this.first) {
if (this.is224) {
ab = 300032;
t1 = blocks[0] - 1413257819;
h = t1 - 150054599 << 0;
d = t1 + 24177077 << 0;
} else {
ab = 704751109;
t1 = blocks[0] - 210244248;
h = t1 - 1521486534 << 0;
d = t1 + 143694565 << 0;
}
this.first = false;
} else {
s0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10));
s1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7));
ab = a & b;
maj = ab ^ (a & c) ^ bc;
ch = (e & f) ^ (~e & g);
t1 = h + s1 + ch + K[j] + blocks[j];
t2 = s0 + maj;
h = d + t1 << 0;
d = t1 + t2 << 0;
}
s0 = ((d >>> 2) | (d << 30)) ^ ((d >>> 13) | (d << 19)) ^ ((d >>> 22) | (d << 10));
s1 = ((h >>> 6) | (h << 26)) ^ ((h >>> 11) | (h << 21)) ^ ((h >>> 25) | (h << 7));
da = d & a;
maj = da ^ (d & b) ^ ab;
ch = (h & e) ^ (~h & f);
t1 = g + s1 + ch + K[j + 1] + blocks[j + 1];
t2 = s0 + maj;
g = c + t1 << 0;
c = t1 + t2 << 0;
s0 = ((c >>> 2) | (c << 30)) ^ ((c >>> 13) | (c << 19)) ^ ((c >>> 22) | (c << 10));
s1 = ((g >>> 6) | (g << 26)) ^ ((g >>> 11) | (g << 21)) ^ ((g >>> 25) | (g << 7));
cd = c & d;
maj = cd ^ (c & a) ^ da;
ch = (g & h) ^ (~g & e);
t1 = f + s1 + ch + K[j + 2] + blocks[j + 2];
t2 = s0 + maj;
f = b + t1 << 0;
b = t1 + t2 << 0;
s0 = ((b >>> 2) | (b << 30)) ^ ((b >>> 13) | (b << 19)) ^ ((b >>> 22) | (b << 10));
s1 = ((f >>> 6) | (f << 26)) ^ ((f >>> 11) | (f << 21)) ^ ((f >>> 25) | (f << 7));
bc = b & c;
maj = bc ^ (b & d) ^ cd;
ch = (f & g) ^ (~f & h);
t1 = e + s1 + ch + K[j + 3] + blocks[j + 3];
t2 = s0 + maj;
e = a + t1 << 0;
a = t1 + t2 << 0;
}
this.h0 = this.h0 + a << 0;
this.h1 = this.h1 + b << 0;
this.h2 = this.h2 + c << 0;
this.h3 = this.h3 + d << 0;
this.h4 = this.h4 + e << 0;
this.h5 = this.h5 + f << 0;
this.h6 = this.h6 + g << 0;
this.h7 = this.h7 + h << 0;
};
Sha256.prototype.hex = function () {
this.finalize();
var h0 = this.h0, h1 = this.h1, h2 = this.h2, h3 = this.h3, h4 = this.h4, h5 = this.h5,
h6 = this.h6, h7 = this.h7;
var hex = HEX_CHARS[(h0 >> 28) & 0x0F] + HEX_CHARS[(h0 >> 24) & 0x0F] +
HEX_CHARS[(h0 >> 20) & 0x0F] + HEX_CHARS[(h0 >> 16) & 0x0F] +
HEX_CHARS[(h0 >> 12) & 0x0F] + HEX_CHARS[(h0 >> 8) & 0x0F] +
HEX_CHARS[(h0 >> 4) & 0x0F] + HEX_CHARS[h0 & 0x0F] +
HEX_CHARS[(h1 >> 28) & 0x0F] + HEX_CHARS[(h1 >> 24) & 0x0F] +
HEX_CHARS[(h1 >> 20) & 0x0F] + HEX_CHARS[(h1 >> 16) & 0x0F] +
HEX_CHARS[(h1 >> 12) & 0x0F] + HEX_CHARS[(h1 >> 8) & 0x0F] +
HEX_CHARS[(h1 >> 4) & 0x0F] + HEX_CHARS[h1 & 0x0F] +
HEX_CHARS[(h2 >> 28) & 0x0F] + HEX_CHARS[(h2 >> 24) & 0x0F] +
HEX_CHARS[(h2 >> 20) & 0x0F] + HEX_CHARS[(h2 >> 16) & 0x0F] +
HEX_CHARS[(h2 >> 12) & 0x0F] + HEX_CHARS[(h2 >> 8) & 0x0F] +
HEX_CHARS[(h2 >> 4) & 0x0F] + HEX_CHARS[h2 & 0x0F] +
HEX_CHARS[(h3 >> 28) & 0x0F] + HEX_CHARS[(h3 >> 24) & 0x0F] +
HEX_CHARS[(h3 >> 20) & 0x0F] + HEX_CHARS[(h3 >> 16) & 0x0F] +
HEX_CHARS[(h3 >> 12) & 0x0F] + HEX_CHARS[(h3 >> 8) & 0x0F] +
HEX_CHARS[(h3 >> 4) & 0x0F] + HEX_CHARS[h3 & 0x0F] +
HEX_CHARS[(h4 >> 28) & 0x0F] + HEX_CHARS[(h4 >> 24) & 0x0F] +
HEX_CHARS[(h4 >> 20) & 0x0F] + HEX_CHARS[(h4 >> 16) & 0x0F] +
HEX_CHARS[(h4 >> 12) & 0x0F] + HEX_CHARS[(h4 >> 8) & 0x0F] +
HEX_CHARS[(h4 >> 4) & 0x0F] + HEX_CHARS[h4 & 0x0F] +
HEX_CHARS[(h5 >> 28) & 0x0F] + HEX_CHARS[(h5 >> 24) & 0x0F] +
HEX_CHARS[(h5 >> 20) & 0x0F] + HEX_CHARS[(h5 >> 16) & 0x0F] +
HEX_CHARS[(h5 >> 12) & 0x0F] + HEX_CHARS[(h5 >> 8) & 0x0F] +
HEX_CHARS[(h5 >> 4) & 0x0F] + HEX_CHARS[h5 & 0x0F] +
HEX_CHARS[(h6 >> 28) & 0x0F] + HEX_CHARS[(h6 >> 24) & 0x0F] +
HEX_CHARS[(h6 >> 20) & 0x0F] + HEX_CHARS[(h6 >> 16) & 0x0F] +
HEX_CHARS[(h6 >> 12) & 0x0F] + HEX_CHARS[(h6 >> 8) & 0x0F] +
HEX_CHARS[(h6 >> 4) & 0x0F] + HEX_CHARS[h6 & 0x0F];
if (!this.is224) {
hex += HEX_CHARS[(h7 >> 28) & 0x0F] + HEX_CHARS[(h7 >> 24) & 0x0F] +
HEX_CHARS[(h7 >> 20) & 0x0F] + HEX_CHARS[(h7 >> 16) & 0x0F] +
HEX_CHARS[(h7 >> 12) & 0x0F] + HEX_CHARS[(h7 >> 8) & 0x0F] +
HEX_CHARS[(h7 >> 4) & 0x0F] + HEX_CHARS[h7 & 0x0F];
}
return hex;
};
Sha256.prototype.toString = Sha256.prototype.hex;
Sha256.prototype.digest = function () {
this.finalize();
var h0 = this.h0, h1 = this.h1, h2 = this.h2, h3 = this.h3, h4 = this.h4, h5 = this.h5,
h6 = this.h6, h7 = this.h7;
var arr = [
(h0 >> 24) & 0xFF, (h0 >> 16) & 0xFF, (h0 >> 8) & 0xFF, h0 & 0xFF,
(h1 >> 24) & 0xFF, (h1 >> 16) & 0xFF, (h1 >> 8) & 0xFF, h1 & 0xFF,
(h2 >> 24) & 0xFF, (h2 >> 16) & 0xFF, (h2 >> 8) & 0xFF, h2 & 0xFF,
(h3 >> 24) & 0xFF, (h3 >> 16) & 0xFF, (h3 >> 8) & 0xFF, h3 & 0xFF,
(h4 >> 24) & 0xFF, (h4 >> 16) & 0xFF, (h4 >> 8) & 0xFF, h4 & 0xFF,
(h5 >> 24) & 0xFF, (h5 >> 16) & 0xFF, (h5 >> 8) & 0xFF, h5 & 0xFF,
(h6 >> 24) & 0xFF, (h6 >> 16) & 0xFF, (h6 >> 8) & 0xFF, h6 & 0xFF
];
if (!this.is224) {
arr.push((h7 >> 24) & 0xFF, (h7 >> 16) & 0xFF, (h7 >> 8) & 0xFF, h7 & 0xFF);
}
return arr;
};
Sha256.prototype.array = Sha256.prototype.digest;
Sha256.prototype.arrayBuffer = function () {
this.finalize();
var buffer = new ArrayBuffer(this.is224 ? 28 : 32);
var dataView = new DataView(buffer);
dataView.setUint32(0, this.h0);
dataView.setUint32(4, this.h1);
dataView.setUint32(8, this.h2);
dataView.setUint32(12, this.h3);
dataView.setUint32(16, this.h4);
dataView.setUint32(20, this.h5);
dataView.setUint32(24, this.h6);
if (!this.is224) {
dataView.setUint32(28, this.h7);
}
return buffer;
};
function HmacSha256(key, is224, sharedMemory) {
var i, type = typeof key;
if (type === 'string') {
var bytes = [], length = key.length, index = 0, code;
for (i = 0; i < length; ++i) {
code = key.charCodeAt(i);
if (code < 0x80) {
bytes[index++] = code;
} else if (code < 0x800) {
bytes[index++] = (0xc0 | (code >> 6));
bytes[index++] = (0x80 | (code & 0x3f));
} else if (code < 0xd800 || code >= 0xe000) {
bytes[index++] = (0xe0 | (code >> 12));
bytes[index++] = (0x80 | ((code >> 6) & 0x3f));
bytes[index++] = (0x80 | (code & 0x3f));
} else {
code = 0x10000 + (((code & 0x3ff) << 10) | (key.charCodeAt(++i) & 0x3ff));
bytes[index++] = (0xf0 | (code >> 18));
bytes[index++] = (0x80 | ((code >> 12) & 0x3f));
bytes[index++] = (0x80 | ((code >> 6) & 0x3f));
bytes[index++] = (0x80 | (code & 0x3f));
}
}
key = bytes;
} else {
if (type === 'object') {
if (key === null) {
throw new Error(ERROR);
} else if (ARRAY_BUFFER && key.constructor === ArrayBuffer) {
key = new Uint8Array(key);
} else if (!Array.isArray(key)) {
if (!ARRAY_BUFFER || !ArrayBuffer.isView(key)) {
throw new Error(ERROR);
}
}
} else {
throw new Error(ERROR);
}
}
if (key.length > 64) {
key = (new Sha256(is224, true)).update(key).array();
}
var oKeyPad = [], iKeyPad = [];
for (i = 0; i < 64; ++i) {
var b = key[i] || 0;
oKeyPad[i] = 0x5c ^ b;
iKeyPad[i] = 0x36 ^ b;
}
Sha256.call(this, is224, sharedMemory);
this.update(iKeyPad);
this.oKeyPad = oKeyPad;
this.inner = true;
this.sharedMemory = sharedMemory;
}
HmacSha256.prototype = new Sha256();
HmacSha256.prototype.finalize = function () {
Sha256.prototype.finalize.call(this);
if (this.inner) {
this.inner = false;
var innerHash = this.array();
Sha256.call(this, this.is224, this.sharedMemory);
this.update(this.oKeyPad);
this.update(innerHash);
Sha256.prototype.finalize.call(this);
}
};
var exports = createMethod();
exports.sha256 = exports;
exports.sha224 = createMethod(true);
exports.sha256.hmac = createHmacMethod();
exports.sha224.hmac = createHmacMethod(true);
if (COMMON_JS) {
module.exports = exports;
} else {
root.sha256 = exports.sha256;
root.sha224 = exports.sha224;
if (AMD) {
define(function () {
return exports;
});
}
}
})();

87
src/conf/conf.go

@ -0,0 +1,87 @@
/*
dela - web TODO list
Copyright (C) 2023 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 conf
import (
"encoding/json"
"io"
"os"
)
type Conf struct {
Port uint16 `json:"port"`
CertFilePath string `json:"cert_file_path"`
KeyFilePath string `json:"key_file_path"`
BaseContentDir string `json:"base_content_dir"`
ProdDBName string `json:"production_db_name"`
}
// Creates a default server configuration
func Default() Conf {
return Conf{
Port: 8080,
CertFilePath: "",
KeyFilePath: "",
BaseContentDir: ".",
ProdDBName: "dela.db",
}
}
// Tries to retrieve configuration from given json file
func FromFile(path string) (Conf, error) {
configFile, err := os.Open(path)
if err != nil {
return Default(), err
}
defer configFile.Close()
confBytes, err := io.ReadAll(configFile)
if err != nil {
return Default(), err
}
var config Conf
err = json.Unmarshal(confBytes, &config)
if err != nil {
return Default(), err
}
return config, nil
}
// Create empty configuration file
func Create(path string, conf Conf) (Conf, error) {
configFile, err := os.Create(path)
if err != nil {
return Default(), err
}
defer configFile.Close()
configJsonBytes, err := json.MarshalIndent(conf, "", " ")
if err != nil {
return conf, err
}
_, err = configFile.Write(configJsonBytes)
if err != nil {
return conf, nil
}
return conf, nil
}

113
src/db/db.go

@ -0,0 +1,113 @@
/*
dela - web TODO list
Copyright (C) 2023 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"
"os"
_ "modernc.org/sqlite"
)
// Database wrapper
type DB struct {
*sql.DB
}
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)`,
)
if err != nil {
return err
}
// Todo groups
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS todo_groups(
id INTEGER PRIMARY KEY AUTOINCREMENT UNIQUE,
name TEXT,
time_created_unix INTEGER,
owner_login TEXT NOT NULL,
FOREIGN KEY(owner_login) REFERENCES users(login))`,
)
if err != nil {
return err
}
// Todos
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS todos(
id INTEGER PRIMARY KEY AUTOINCREMENT UNIQUE,
group_id INTEGER NOT NULL,
text TEXT NOT NULL,
time_created_unix INTEGER,
due_unix INTEGER,
owner_login TEXT NOT NULL,
is_done INTEGER,
completion_time_unix INTEGER,
FOREIGN KEY(group_id) REFERENCES todo_groups(id),
FOREIGN KEY(owner_login) REFERENCES users(login))`,
)
if err != nil {
return err
}
return nil
}
// Open database
func FromFile(path string) (*DB, error) {
driver, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
dbase := &DB{driver}
err = setUpTables(dbase)
if err != nil {
return nil, err
}
return dbase, nil
}
// Create database file
func Create(path string) (*DB, error) {
dbFile, err := os.Create(path)
if err != nil {
return nil, err
}
dbFile.Close()
driver, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
dbase := &DB{driver}
err = setUpTables(dbase)
if err != nil {
return nil, err
}
return dbase, nil
}

97
src/db/db_test.go

@ -0,0 +1,97 @@
/*
dela - web TODO list
Copyright (C) 2023 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 (
"os"
"path/filepath"
"testing"
)
func TestApi(t *testing.T) {
db, err := Create(filepath.Join(os.TempDir(), "dela_test.db"))
if err != nil {
t.Fatalf("failed to create database: %s", err)
}
// User
user := User{
Login: "user1",
Password: "ruohguoeruoger",
TimeCreatedUnix: 12421467,
}
err = db.CreateUser(user)
if err != nil {
t.Fatalf("failed to create user: %s", err)
}
dbUser, err := db.GetUser(user.Login)
if err != nil {
t.Fatalf("failed to retrieve created user: %s", err)
}
if dbUser.Password != user.Password {
t.Fatalf("user passwords don't match")
}
// Todos
group := TodoGroup{
Name: "group1",
TimeCreatedUnix: 13524534,
OwnerLogin: user.Login,
}
err = db.CreateTodoGroup(group)
if err != nil {
t.Fatalf("failed to create todo group: %s", err)
}
err = db.UpdateTodoGroup(1, TodoGroup{Name: "updated_name"})
if err != nil {
t.Fatalf("failed to update todo group: %s", err)
}
dbGroup, err := db.GetTodoGroup(1)
if err != nil {
t.Fatalf("failed to get created TODO group: %s", err)
}
if dbGroup.Name == group.Name {
t.Fatalf("name match changed value for a TODO group")
}
todo := Todo{
GroupID: dbGroup.ID,
Text: "Do the dishes",
TimeCreatedUnix: dbGroup.TimeCreatedUnix,
DueUnix: 0,
OwnerLogin: user.Login,
}
err = db.CreateTodo(todo)
if err != nil {
t.Fatalf("couldn't create a new TODO: %s", err)
}
// Now deletion
err = db.DeleteUserClean(user.Login)
if err != nil {
t.Fatalf("couldn't cleanly delete user with all TODOs: %s", err)
}
}

113
src/db/group.go

@ -0,0 +1,113 @@
package db
import "database/sql"
// Todo group structure
type TodoGroup struct {
ID uint64 `json:"id"`
Name string `json:"name"`
TimeCreatedUnix uint64 `json:"timeCreatedUnix"`
OwnerLogin string `json:"ownerLogin"`
}
// 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(?, ?, ?)",
group.Name,
group.TimeCreatedUnix,
group.OwnerLogin,
)
return err
}
func scanTodoGroup(rows *sql.Rows) (*TodoGroup, error) {
var newTodoGroup TodoGroup
err := rows.Scan(
&newTodoGroup.ID,
&newTodoGroup.Name,
&newTodoGroup.TimeCreatedUnix,
&newTodoGroup.OwnerLogin,
)
if err != nil {
return nil, err
}
return &newTodoGroup, nil
}
// Retrieves a TODO group with provided ID from the database
func (db *DB) GetTodoGroup(id uint64) (*TodoGroup, error) {
rows, err := db.Query(
"SELECT * FROM todo_groups WHERE id=?",
id,
)
if err != nil {
return nil, err
}
defer rows.Close()
rows.Next()
todoGroup, err := scanTodoGroup(rows)
if err != nil {
return nil, err
}
return todoGroup, nil
}
// Retrieves information on ALL TODO groups
func (db *DB) GetTodoGroups() ([]*TodoGroup, error) {
var groups []*TodoGroup
rows, err := db.Query("SELECT * FROM todo_groups")
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
todoGroup, err := scanTodoGroup(rows)
if err != nil {
return groups, err
}
groups = append(groups, todoGroup)
}
return groups, nil
}
// Deletes information about a TODO group of given ID from the database
func (db *DB) DeleteTodoGroup(id uint64) error {
_, err := db.Exec(
"DELETE FROM todo_groups WHERE id=?",
id,
)
return err
}
// Updates TODO group's name
func (db *DB) UpdateTodoGroup(groupID uint64, updatedGroup TodoGroup) error {
_, err := db.Exec(
"UPDATE todo_groups SET name=? WHERE id=?",
updatedGroup.Name,
groupID,
)
return err
}
func (db *DB) DoesUserOwnGroup(groupId uint64, login string) bool {
group, err := db.GetTodoGroup(groupId)
if err != nil {
return false
}
if group.OwnerLogin != login {
return false
}
return true
}

216
src/db/todo.go

@ -0,0 +1,216 @@
/*
dela - web TODO list
Copyright (C) 2023 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"
// Todo structure
type Todo struct {
ID uint64 `json:"id"`
GroupID uint64 `json:"groupId"`
Text string `json:"text"`
TimeCreatedUnix uint64 `json:"timeCreatedUnix"`
DueUnix uint64 `json:"dueUnix"`
OwnerLogin string `json:"ownerLogin"`
IsDone bool `json:"isDone"`
CompletionTimeUnix uint64 `json:"completionTimeUnix"`
}
func scanTodo(rows *sql.Rows) (*Todo, error) {
var newTodo Todo
err := rows.Scan(
&newTodo.ID,
&newTodo.GroupID,
&newTodo.Text,
&newTodo.TimeCreatedUnix,
&newTodo.DueUnix,
&newTodo.OwnerLogin,
&newTodo.IsDone,
&newTodo.CompletionTimeUnix,
)
if err != nil {
return nil, err
}
return &newTodo, nil
}
// Retrieves a TODO with given Id from the database
func (db *DB) GetTodo(id uint64) (*Todo, error) {
rows, err := db.Query(
"SELECT * FROM todos WHERE id=?",
id,
)
if err != nil {
return nil, err
}
defer rows.Close()
rows.Next()
todo, err := scanTodo(rows)
if err != nil {
return nil, err
}
return todo, nil
}
// Retrieves information on ALL TODOs
func (db *DB) GetTodos() ([]*Todo, error) {
var todos []*Todo
rows, err := db.Query("SELECT * FROM todos")
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
todo, err := scanTodo(rows)
if err != nil {
return todos, err
}
todos = append(todos, todo)
}
return todos, nil
}
// 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(?, ?, ?, ?, ?, ?, ?)",
todo.GroupID,
todo.Text,
todo.TimeCreatedUnix,
todo.DueUnix,
todo.OwnerLogin,
todo.IsDone,
todo.CompletionTimeUnix,
)
return err
}
// Deletes information about a TODO of certain ID from the database
func (db *DB) DeleteTodo(id uint64) error {
_, err := db.Exec(
"DELETE FROM todos WHERE id=?",
id,
)
return err
}
// Updates TODO's due date, text, done state, completion time and group id
func (db *DB) UpdateTodo(todoID uint64, updatedTodo Todo) error {
_, err := db.Exec(
"UPDATE todos SET group_id=?, due_unix=?, text=?, is_done=?, completion_time_unix=? WHERE id=?",
updatedTodo.GroupID,
updatedTodo.DueUnix,
updatedTodo.Text,
updatedTodo.IsDone,
updatedTodo.CompletionTimeUnix,
todoID,
)
return err
}
// Searches and retrieves TODO groups created by the user
func (db *DB) GetAllUserTodoGroups(username string) ([]*TodoGroup, error) {
var todoGroups []*TodoGroup
rows, err := db.Query(
"SELECT * FROM todo_groups WHERE owner_username=?",
username,
)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
group, err := scanTodoGroup(rows)
if err != nil {
continue
}
todoGroups = append(todoGroups, group)
}
return todoGroups, nil
}
// Searches and retrieves TODOs created by the user
func (db *DB) GetAllUserTodos(username string) ([]*Todo, error) {
var todos []*Todo
rows, err := db.Query(
"SELECT * FROM todos WHERE owner_username=?",
username,
)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
todo, err := scanTodo(rows)
if err != nil {
continue
}
todos = append(todos, todo)
}
return todos, nil
}
// Deletes all information regarding TODOs of specified user
func (db *DB) DeleteAllUserTodos(username string) error {
_, err := db.Exec(
"DELETE FROM todos WHERE owner_username=?",
username,
)
return err
}
// Deletes all information regarding TODO groups of specified user
func (db *DB) DeleteAllUserTodoGroups(username string) error {
_, err := db.Exec(
"DELETE FROM todo_groups WHERE owner_username=?",
username,
)
return err
}
func (db *DB) DoesUserOwnTodo(todoId uint64, login string) bool {
todo, err := db.GetTodo(todoId)
if err != nil {
return false
}
if todo.OwnerLogin != login {
return false
}
return true
}

110
src/db/user.go

@ -0,0 +1,110 @@
/*
dela - web TODO list
Copyright (C) 2023 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"
// User structure
type User struct {
Login string `json:"login"`
Email string `json:"email"`
Password string `json:"password"`
TimeCreatedUnix uint64 `json:"timeCreatedUnix"`
}
func scanUser(rows *sql.Rows) (*User, error) {
rows.Next()
var user User
err := rows.Scan(&user.Login, &user.Email, &user.Password, &user.TimeCreatedUnix)
if err != nil {
return nil, err
}
return &user, nil
}
// Searches for user with login and returns it
func (db *DB) GetUser(login string) (*User, error) {
rows, err := db.Query("SELECT * FROM users WHERE login=?", login)
if err != nil {
return nil, err
}
defer rows.Close()
user, err := scanUser(rows)
if err != nil {
return nil, err
}
return user, nil
}
// Creates a new user in the database
func (db *DB) CreateUser(newUser User) error {
_, err := db.Exec(
"INSERT INTO users(login, email, password, time_created_unix) VALUES(?, ?, ?, ?)",
newUser.Login,
newUser.Email,
newUser.Password,
newUser.TimeCreatedUnix,
)
return err
}
// Deletes user with given login
func (db *DB) DeleteUser(login string) error {
_, err := db.Exec(
"DELETE FROM users WHERE login=?",
login,
)
return err
}
func (db *DB) UserUpdate(newUser User) error {
_, err := db.Exec(
"UPDATE users SET email=? password=? WHERE login=?",
newUser.Email,
newUser.Password,
newUser.Login,
)
return err
}
// Deletes a user and all his TODOs (with groups) as well
func (db *DB) DeleteUserClean(login string) error {
err := db.DeleteAllUserTodoGroups(login)
if err != nil {
return err
}
err = db.DeleteAllUserTodos(login)
if err != nil {
return err
}
err = db.DeleteUser(login)
if err != nil {
return err
}
return nil
}

43
src/encryption/encryption.go

@ -0,0 +1,43 @@
/*
dela - web TODO list
Copyright (C) 2023 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 encryption
import (
"crypto/sha256"
"encoding/base64"
"fmt"
)
// Encodes given string via Base64
func EncodeString(str string) string {
return base64.StdEncoding.EncodeToString([]byte(str))
}
// Decodes given string via Base64
func DecodeString(encodedStr string) string {
decodedBytes, _ := base64.StdEncoding.DecodeString(encodedStr)
return string(decodedBytes)
}
// Returns HEX string of SHA256'd data
func SHA256Hex(data []byte) string {
hash := sha256.New()
hash.Write(data)
return fmt.Sprintf("%x", hash.Sum(nil))
}

26
src/go.mod

@ -0,0 +1,26 @@
module Unbewohnte/dela
go 1.20
require modernc.org/sqlite v1.22.0
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/mod v0.3.0 // indirect
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect
modernc.org/ccgo/v3 v3.16.13 // indirect
modernc.org/libc v1.22.4 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/token v1.0.1 // indirect
)

65
src/go.sum

@ -0,0 +1,65 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 h1:M8tBwCtWD/cZV9DZpFYRUgaymAYAr+aIUTWzDaM3uPs=
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
modernc.org/libc v1.22.4 h1:wymSbZb0AlrjdAVX3cjreCHTPCpPARbQXNz6BHPzdwQ=
modernc.org/libc v1.22.4/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.22.0 h1:Uo+wEWePCspy4SAu0w2VbzUHEftOs7yoaWX/cYjsq84=
modernc.org/sqlite v1.22.0/go.mod h1:cxbLkB5WS32DnQqeH4h4o1B0eMr8W/y8/RGuxQ3JsC0=
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/tcl v1.15.1 h1:mOQwiEK4p7HruMZcwKTZPw/aqtGM4aY00uzWhlKKYws=
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.7.0 h1:xkDw/KepgEjeizO2sNco+hqYkU12taxQFqPEmgm1GWE=

60
src/logger/logger.go

@ -0,0 +1,60 @@
/*
dela - web TODO list
Copyright (C) 2023 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 logger
import (
"io"
"log"
"os"
)
// 3 basic loggers in global space
var (
// neutral information logger
infoLog *log.Logger
// warning-level information logger
warningLog *log.Logger
// errors information logger
errorLog *log.Logger
)
func init() {
infoLog = log.New(os.Stdout, "[INFO] ", log.Ldate|log.Ltime)
warningLog = log.New(os.Stdout, "[WARNING] ", log.Ldate|log.Ltime)
errorLog = log.New(os.Stdout, "[ERROR] ", log.Ldate|log.Ltime)
}
// Set up loggers to write to the given writer
func SetOutput(writer io.Writer) {
if writer == nil {
writer = io.Discard
}
infoLog.SetOutput(writer)
warningLog.SetOutput(writer)
errorLog.SetOutput(writer)
}
func Info(format string, a ...interface{}) {
infoLog.Printf(format, a...)
}
func Warning(format string, a ...interface{}) {
warningLog.Printf(format, a...)
}
func Error(format string, a ...interface{}) {
errorLog.Printf(format, a...)
}

96
src/main.go

@ -0,0 +1,96 @@
/*
dela - web TODO list
Copyright (C) 2023 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 main
import (
"Unbewohnte/dela/conf"
"Unbewohnte/dela/logger"
"Unbewohnte/dela/server"
"flag"
"fmt"
"os"
"path/filepath"
)
const Version string = "0.1.2"
var (
printVersion *bool = flag.Bool("version", false, "Print version information and exit")
)
const ConfName string = "conf.json"
var (
WDir string
Conf conf.Conf
)
func init() {
// Parse flags
flag.Parse()
if *printVersion {
fmt.Printf("dela v%s - a web TODO list\n(c) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte)\n", Version)
os.Exit(0)
}
// Initialize logging
logger.SetOutput(os.Stdout)
// Work out the working directory
exePath, err := os.Executable()
if err != nil {
logger.Error("[Init] Failed to retrieve executable's path: %s", err)
os.Exit(1)
}
WDir = filepath.Dir(exePath)
logger.Info("[Init] Working in \"%s\"", WDir)
// Open configuration, create if does not exist
Conf, err = conf.FromFile(filepath.Join(WDir, ConfName))
if err != nil {
_, err = conf.Create(filepath.Join(WDir, ConfName), conf.Default())
if err != nil {
logger.Error("[Init] Failed to create a new configuration file: %s", err)
os.Exit(1)
}
logger.Info("[Init] Created a new configuration file")
os.Exit(0)
}
logger.Info("[Init] Opened existing configuration file")
if Conf.BaseContentDir == "." {
Conf.BaseContentDir = WDir
}
logger.Info("[Init] Successful initializaion!")
}
func main() {
server, err := server.New(Conf)
if err != nil {
logger.Error("[Main] Failed to initialize a new server with conf (%+v): %s", Conf, err)
return
}
logger.Info("[Main] Successfully initialized a new server instance with conf (%+v)", Conf)
err = server.Start()
if err != nil {
logger.Error("[Main] Fatal server failure: %s. Exiting...", err)
return
}
}

146
src/server/api_test.go

@ -0,0 +1,146 @@
/*
dela - web TODO list
Copyright (C) 2023 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 server
import (
"Unbewohnte/dela/conf"
"Unbewohnte/dela/db"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"testing"
"time"
)
func TestApi(t *testing.T) {
// Create a new server
config := conf.Default()
config.BaseContentDir = "../../"
config.ProdDBName = filepath.Join(os.TempDir(), "dela_test_db.db")
server, err := New(config)
if err != nil {
t.Fatalf("failed to create a new server: %s", err)
}
defer os.Remove(config.ProdDBName)
go func() {
time.Sleep(time.Second * 5)
server.Stop()
}()
go func() {
server.Start()
}()
// Create a new user
newUser := db.User{
Login: "user1",
Password: "ruohguoeruoger",
TimeCreatedUnix: 12421467,
}
newUserJsonBytes, err := json.Marshal(&newUser)
if err != nil {
t.Fatalf("could not marshal new user JSON: %s", err)
}
resp, err := http.Post(fmt.Sprintf("http://localhost:%d/api/user", config.Port), "application/json", bytes.NewBuffer(newUserJsonBytes))
if err != nil {
t.Fatalf("failed to post a new user data: %s", err)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read response body for user creation: %s", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("got non-OK status code for user creation: %s", string(body))
}
resp.Body.Close()
// Create a new TODO
// newGroup := db.TodoGroup{
// Name: "group1",
// TimeCreatedUnix: 13524534,
// OwnerUsername: newUser.Login,
// }
// newGroupBytes, err := json.Marshal(&newGroup)
// if err != nil {
// t.Fatalf("could not marshal new user JSON: %s", err)
// }
// req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/groups", config.Port), bytes.NewBuffer(newGroupBytes))
// if err != nil {
// t.Fatalf("failed to create a new POST request to create a new TODO group: %s", err)
// }
// req.Header.Add(RequestHeaderAuthKey, fmt.Sprintf("%s%s%s", newUser.Login, RequestHeaderAuthSeparator, newUser.Password))
// req.Header.Add(RequestHeaderEncodedB64, "false")
// resp, err = http.DefaultClient.Do(req)
// if err != nil {
// t.Fatalf("failed to post a new TODO group: %s", err)
// }
body, err = io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read response body for TODO group creation: %s", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("got non-OK status code for TODO group creation: %s", string(body))
}
resp.Body.Close()
// TODO creation
var newTodo db.Todo = db.Todo{
GroupID: 0,
Text: "Do the dishes",
TimeCreatedUnix: uint64(time.Now().UnixMicro()),
DueUnix: uint64(time.Now().Add(time.Hour * 5).UnixMicro()),
OwnerLogin: newUser.Login,
}
newTodoBytes, err := json.Marshal(&newTodo)
if err != nil {
t.Fatalf("could not marshal new Todo: %s", err)
}
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/todo", config.Port), bytes.NewBuffer(newTodoBytes))
if err != nil {
t.Fatalf("failed to create a new POST request to create a new TODO: %s", err)
}
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("failed to post a new Todo: %s", err)
}
body, err = io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read response body for Todo creation: %s", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("got non-OK status code for Todo creation: %s", string(body))
}
resp.Body.Close()
}

510
src/server/endpoints.go

@ -0,0 +1,510 @@
/*
dela - web TODO list
Copyright (C) 2023 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 server
import (
"Unbewohnte/dela/db"
"Unbewohnte/dela/logger"
"encoding/json"
"io"
"net/http"
"path"
"strconv"
"time"
)
func (s *Server) EndpointUserCreate(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Retrieve user data
defer req.Body.Close()
contents, err := io.ReadAll(req.Body)
if err != nil {
logger.Error("[Server][EndpointUserCreate] Failed to read request body: %s", err)
http.Error(w, "Failed to read request body", http.StatusInternalServerError)
return
}
var user db.User
err = json.Unmarshal(contents, &user)
if err != nil {
logger.Error("[Server][EndpointUserCreate] Failed to unmarshal user data: %s", err)
http.Error(w, "User JSON unmarshal error", http.StatusInternalServerError)
return
}
// Sanitize
valid, reason := IsUserValid(user)
if !valid {
http.Error(w, reason, http.StatusInternalServerError)
return
}
user.TimeCreatedUnix = uint64(time.Now().Unix())
// Insert into DB
err = s.db.CreateUser(user)
if err != nil {
logger.Error("[Server][EndpointUserCreate] Failed to insert new user \"%s\" data: %s", user.Login, err)
http.Error(w, "Failed to create user", http.StatusInternalServerError)
return
}
logger.Info("[Server][EndpointUserCreate] Created a new user with login \"%s\"", user.Login)
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)
}
// Retrieve user data
defer req.Body.Close()
// Authentication check
if !IsUserAuthorizedReq(req, s.db) {
http.Error(w, "Authentication error", http.StatusForbidden)
return
}
contents, err := io.ReadAll(req.Body)
if err != nil {
logger.Error("[Server][EndpointUserUpdate] Failed to read request body: %s", err)
http.Error(w, "Failed to read request body", http.StatusInternalServerError)
return
}
var user db.User
err = json.Unmarshal(contents, &user)
if err != nil {
logger.Error("[Server][EndpointUserUpdate] Failed to unmarshal user data: %s", err)
http.Error(w, "User JSON unmarshal error", http.StatusInternalServerError)
return
}
// Check whether the user in request is the user specified in JSON
login, _, _ := req.BasicAuth()
if login != user.Login {
// Gotcha!
logger.Warning("[Server][EndpointUserUpdate] %s tried to update user information of %s!", login, user.Login)
http.Error(w, "Logins do not match", http.StatusForbidden)
return
}
// Update
err = s.db.UserUpdate(user)
if err != nil {
http.Error(w, "Failed to update user", http.StatusInternalServerError)
logger.Error("[Server][EndpointUserUpdate] Failed to update \"%s\": %s", user.Login, err)
return
}
logger.Info("[Server][EndpointUserUpdate] Updated a user with login \"%s\"", user.Login)
w.WriteHeader(http.StatusOK)
}
func (s *Server) EndpointUserDelete(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
defer req.Body.Close()
// Authentication check
if !IsUserAuthorizedReq(req, s.db) {
http.Error(w, "Authentication error", http.StatusForbidden)
return
}
// Delete
login, _, _ := req.BasicAuth()
err := s.db.DeleteUser(login)
if err != nil {
http.Error(w, "Failed to delete user", http.StatusInternalServerError)
logger.Error("[Server][EndpointUserDelete] Failed to delete \"%s\": %s", login, err)
return
}
logger.Info("[Server][EndpointUserDelete] Deleted a user with login \"%s\"", login)
w.WriteHeader(http.StatusOK)
}
func (s *Server) EndpointUserGet(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
defer req.Body.Close()
// Authentication check
if !IsUserAuthorizedReq(req, s.db) {
http.Error(w, "Authentication error", http.StatusForbidden)
return
}
// Get information from the database
login, _, _ := req.BasicAuth()
userDB, err := s.db.GetUser(login)
if err != nil {
logger.Error("[Server][EndpointUserGet] Failed to retrieve information on \"%s\": %s", login, err)
http.Error(w, "Failed to fetch information", http.StatusInternalServerError)
return
}
userDBBytes, err := json.Marshal(&userDB)
if err != nil {
logger.Error("[Server][EndpointUserGet] Failed to marshal information on \"%s\": %s", login, err)
http.Error(w, "Failed to marshal information", http.StatusInternalServerError)
return
}
// Send
w.Write(userDBBytes)
}
func (s *Server) EndpointTodoUpdate(w http.ResponseWriter, req *http.Request) {
defer req.Body.Close()
if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Check authentication information
if !IsUserAuthorizedReq(req, s.db) {
http.Error(w, "Invalid user auth data", http.StatusForbidden)
return
}
// Obtain TODO ID
todoIDStr := path.Base(req.URL.Path)
todoID, err := strconv.ParseUint(todoIDStr, 10, 64)
if err != nil {
http.Error(w, "Invalid TODO ID", http.StatusBadRequest)
return
}
// Check if the user owns this TODO
if !s.db.DoesUserOwnTodo(todoID, GetLoginFromAuth(req)) {
http.Error(w, "You don't own this TODO", http.StatusForbidden)
return
}
// Read body
body, err := io.ReadAll(req.Body)
if err != nil {
logger.Warning("[Server] Failed to read request body to possibly update a TODO: %s", err)
http.Error(w, "Failed to read body", http.StatusInternalServerError)
return
}
// Unmarshal JSON
var updatedTodo db.Todo
err = json.Unmarshal(body, &updatedTodo)
if err != nil {
logger.Warning("[Server] Received invalid TODO JSON in order to update: %s", err)
http.Error(w, "Invalid TODO JSON", http.StatusBadRequest)
return
}
// Update. (Creation date, owner username and an ID do not change)
err = s.db.UpdateTodo(todoID, updatedTodo)
if err != nil {
logger.Warning("[Server] Failed to update TODO: %s", err)
http.Error(w, "Failed to update", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
logger.Info("[Server] Updated TODO with ID %d", todoID)
}
func (s *Server) EndpointTodoDelete(w http.ResponseWriter, req *http.Request) {
defer req.Body.Close()
if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Delete an existing TODO
// Check if this user actually exists and the password is valid
if !IsUserAuthorizedReq(req, s.db) {
http.Error(w, "Invalid user auth data", http.StatusForbidden)
return
}
// Obtain TODO ID
todoIDStr := path.Base(req.URL.Path)
todoID, err := strconv.ParseUint(todoIDStr, 10, 64)
if err != nil {
http.Error(w, "Invalid TODO ID", http.StatusBadRequest)
return
}
// Check if the user owns this TODO
if !s.db.DoesUserOwnTodo(todoID, GetLoginFromAuth(req)) {
http.Error(w, "You don't own this TODO", http.StatusForbidden)
return
}
// Now delete
err = s.db.DeleteTodo(todoID)
if err != nil {
logger.Error("[Server] Failed to delete %s's TODO: %s", GetLoginFromAuth(req), err)
http.Error(w, "Failed to delete TODO", http.StatusInternalServerError)
return
}
// Success!
logger.Info("[Server] Deleted TODO with ID %d", todoID)
w.WriteHeader(http.StatusOK)
}
func (s *Server) EndpointTodoCreate(w http.ResponseWriter, req *http.Request) {
// Create a new TODO
defer req.Body.Close()
// Read body
body, err := io.ReadAll(req.Body)
if err != nil {
logger.Warning("[Server] Failed to read request body to create a new TODO: %s", err)
http.Error(w, "Failed to read body", http.StatusInternalServerError)
return
}
// Unmarshal JSON
var newTodo db.Todo
err = json.Unmarshal(body, &newTodo)
if err != nil {
logger.Warning("[Server] Received invalid TODO JSON for creation: %s", err)
http.Error(w, "Invalid TODO JSON", http.StatusBadRequest)
return
}
// Check for authentication problems
if !IsUserAuthorizedReq(req, s.db) {
http.Error(w, "Invalid user auth data", http.StatusForbidden)
return
}
// Add TODO to the database
newTodo.OwnerLogin = GetLoginFromAuth(req)
newTodo.TimeCreatedUnix = uint64(time.Now().Unix())
err = s.db.CreateTodo(newTodo)
if err != nil {
http.Error(w, "Failed to create TODO", http.StatusInternalServerError)
logger.Error("[Server] Failed to put a new todo (%+v) into the db: %s", newTodo, err)
return
}
// Success!
w.WriteHeader(http.StatusOK)
logger.Info("[Server] Created a new TODO for %s", newTodo.OwnerLogin)
}
func (s *Server) EndpointUserTodosGet(w http.ResponseWriter, req *http.Request) {
// Retrieve TODO information
defer req.Body.Close()
// Authentication check
if !IsUserAuthorizedReq(req, s.db) {
http.Error(w, "Authentication error", http.StatusForbidden)
return
}
// Check authentication information
if !IsUserAuthorizedReq(req, s.db) {
http.Error(w, "Invalid user auth data", http.StatusForbidden)
return
}
// Get all user TODOs
todos, err := s.db.GetAllUserTodos(GetLoginFromAuth(req))
if err != nil {
http.Error(w, "Failed to get TODOs", http.StatusInternalServerError)
return
}
// Marshal to JSON
todosBytes, err := json.Marshal(&todos)
if err != nil {
http.Error(w, "Failed to marhsal TODOs JSON", http.StatusInternalServerError)
return
}
// Send out
w.Header().Add("Content-Type", "application/json")
w.Write(todosBytes)
}
func (s *Server) EndpointTodoGroupDelete(w http.ResponseWriter, req *http.Request) {
// Delete an existing group
defer req.Body.Close()
// Read body
body, err := io.ReadAll(req.Body)
if err != nil {
logger.Warning("[Server] Failed to read request body to possibly delete a TODO group: %s", err)
http.Error(w, "Failed to read body", http.StatusInternalServerError)
return
}
// Unmarshal JSON
var group db.TodoGroup
err = json.Unmarshal(body, &group)
if err != nil {
logger.Warning("[Server] Received invalid TODO group JSON for deletion: %s", err)
http.Error(w, "Invalid TODO group JSON", http.StatusBadRequest)
return
}
// Check if given user actually owns this group
if !IsUserAuthorizedReq(req, s.db) {
http.Error(w, "Invalid user auth data", http.StatusForbidden)
return
}
if !s.db.DoesUserOwnGroup(group.ID, GetLoginFromAuth(req)) {
http.Error(w, "You don't own this group", http.StatusForbidden)
return
}
// 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)
http.Error(w, "Failed to delete TODO group", http.StatusInternalServerError)
return
}
// Success!
w.WriteHeader(http.StatusOK)
}
func (s *Server) EndpointTodoGroupCreate(w http.ResponseWriter, req *http.Request) {
// Create a new TODO group
defer req.Body.Close()
// Read body
body, err := io.ReadAll(req.Body)
if err != nil {
logger.Warning("[Server] Failed to read request body to create a new TODO group: %s", err)
http.Error(w, "Failed to read body", http.StatusInternalServerError)
return
}
// Unmarshal JSON
var newGroup db.TodoGroup
err = json.Unmarshal(body, &newGroup)
if err != nil {
logger.Warning("[Server] Received invalid TODO group JSON for creation: %s", err)
http.Error(w, "Invalid TODO group JSON", http.StatusBadRequest)
return
}
// Check for authentication problems
if !IsUserAuthorizedReq(req, s.db) {
http.Error(w, "Invalid user auth data", http.StatusForbidden)
return
}
// Add group to the database
newGroup.OwnerLogin = GetLoginFromAuth(req)
newGroup.TimeCreatedUnix = uint64(time.Now().Unix())
err = s.db.CreateTodoGroup(newGroup)
if err != nil {
http.Error(w, "Failed to create TODO group", http.StatusInternalServerError)
return
}
// Success!
w.WriteHeader(http.StatusOK)
logger.Info("[Server] Created a new TODO group for %s", newGroup.OwnerLogin)
}
func (s *Server) EndpointTodoGroupGet(w http.ResponseWriter, req *http.Request) {
// Retrieve all todo groups
defer req.Body.Close()
// Check authentication information
if !IsUserAuthorizedReq(req, s.db) {
http.Error(w, "Invalid user auth data", http.StatusForbidden)
return
}
// Get groups
groups, err := s.db.GetAllUserTodoGroups(GetLoginFromAuth(req))
if err != nil {
http.Error(w, "Failed to get TODO groups", http.StatusInternalServerError)
return
}
// Marshal to JSON
groupBytes, err := json.Marshal(&groups)
if err != nil {
http.Error(w, "Failed to marhsal TODO groups JSON", http.StatusInternalServerError)
return
}
// Send out
w.Header().Add("Content-Type", "application/json")
w.Write(groupBytes)
}
func (s *Server) EndpointTodoGroupUpdate(w http.ResponseWriter, req *http.Request) {
// Check authentication information
if !IsUserAuthorizedReq(req, s.db) {
http.Error(w, "Invalid user auth data", http.StatusForbidden)
return
}
// Read body
body, err := io.ReadAll(req.Body)
if err != nil {
logger.Warning("[Server] Failed to read request body to possibly update a TODO group: %s", err)
http.Error(w, "Failed to read body", http.StatusInternalServerError)
return
}
// Unmarshal JSON
var group db.TodoGroup
err = json.Unmarshal(body, &group)
if err != nil {
logger.Warning("[Server] Received invalid TODO group JSON in order to update: %s", err)
http.Error(w, "Invalid group JSON", http.StatusBadRequest)
return
}
// TODO
err = s.db.UpdateTodoGroup(group.ID, group)
if err != nil {
logger.Warning("[Server] Failed to update TODO group: %s", err)
http.Error(w, "Failed to update", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}

37
src/server/page.go

@ -0,0 +1,37 @@
/*
dela - web TODO list
Copyright (C) 2023 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 server
import (
"html/template"
"path/filepath"
)
// Constructs a pageName template via inserting basePageName in pagesDir
func getPage(pagesDir string, basePageName string, pageName string) (*template.Template, error) {
page, err := template.ParseFiles(
filepath.Join(pagesDir, basePageName),
filepath.Join(pagesDir, pageName),
)
if err != nil {
return nil, err
}
return page, nil
}

178
src/server/server.go

@ -0,0 +1,178 @@
/*
dela - web TODO list
Copyright (C) 2023 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 server
import (
"Unbewohnte/dela/conf"
"Unbewohnte/dela/db"
"Unbewohnte/dela/logger"
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"time"
)
const (
PagesDirName string = "pages"
StaticDirName string = "static"
ScriptsDirName string = "scripts"
)
type Server struct {
config conf.Conf
db *db.DB
http http.Server
}
// Creates a new server instance with provided config
func New(config conf.Conf) (*Server, error) {
var server Server = Server{}
server.config = config
// check if required directories are present
_, err := os.Stat(filepath.Join(config.BaseContentDir, PagesDirName))
if err != nil {
logger.Error("[Server] A directory with HTML pages is not available: %s", err)
return nil, err
}
_, err = os.Stat(filepath.Join(config.BaseContentDir, ScriptsDirName))
if err != nil {
logger.Error("[Server] A directory with scripts is not available: %s", err)
return nil, err
}
_, err = os.Stat(filepath.Join(config.BaseContentDir, StaticDirName))
if err != nil {
logger.Error("[Server] A directory with static content is not available: %s", err)
return nil, err
}
// get database working
serverDB, err := db.FromFile(filepath.Join(config.BaseContentDir, config.ProdDBName))
if err != nil {
// Create one then
serverDB, err = db.Create(filepath.Join(config.BaseContentDir, config.ProdDBName))
if err != nil {
logger.Error("Failed to create a new database: %s", err)
return nil, err
}
}
server.db = serverDB
logger.Info("Opened a database successfully")
// start constructing an http server configuration
server.http = http.Server{
Addr: fmt.Sprintf(":%d", server.config.Port),
}
// configure paths' callbacks
mux := http.NewServeMux()
mux.Handle(
"/static/",
http.StripPrefix("/static/", http.FileServer(
http.Dir(filepath.Join(server.config.BaseContentDir, StaticDirName))),
),
)
mux.Handle(
"/scripts/",
http.StripPrefix("/scripts/", http.FileServer(
http.Dir(filepath.Join(server.config.BaseContentDir, ScriptsDirName))),
),
)
// handle page requests
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
switch req.URL.Path {
case "/":
requestedPage, err := getPage(
filepath.Join(server.config.BaseContentDir, PagesDirName), "base.html", "index.html",
)
if err != nil {
http.Redirect(w, req, "/about", http.StatusTemporaryRedirect)
logger.Error("[Server][/] Failed to get a page: %s", err)
return
}
requestedPage.ExecuteTemplate(w, "index.html", nil)
default:
requestedPage, err := getPage(
filepath.Join(server.config.BaseContentDir, PagesDirName),
"base.html",
req.URL.Path[1:]+".html",
)
if err == nil {
requestedPage.ExecuteTemplate(w, req.URL.Path[1:]+".html", nil)
} else {
http.Error(w, "Page processing error", http.StatusInternalServerError)
}
}
})
mux.HandleFunc("/api/user/get", server.EndpointUserGet) // Non specific
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/get", server.EndpointUserTodosGet) // Non specific
mux.HandleFunc("/api/todo/delete/", server.EndpointTodoDelete) // Specific
mux.HandleFunc("/api/todo/update/", server.EndpointTodoUpdate) // Specific
mux.HandleFunc("/api/group/create", server.EndpointTodoGroupCreate) // Non specific
mux.HandleFunc("/api/group/get/", server.EndpointTodoGroupGet) // Specific
mux.HandleFunc("/api/group/update/", server.EndpointTodoGroupUpdate) // Specific
mux.HandleFunc("/api/group/delete/", server.EndpointTodoGroupDelete) // Specific
server.http.Handler = mux
logger.Info("[Server] Created an HTTP server instance")
return &server, nil
}
// Launches server instance
func (s *Server) Start() error {
if s.config.CertFilePath != "" && s.config.KeyFilePath != "" {
logger.Info("[Server] Using TLS")
logger.Info("[Server] HTTP server is going live on port %d!", s.config.Port)
err := s.http.ListenAndServeTLS(s.config.CertFilePath, s.config.KeyFilePath)
if err != nil && err != http.ErrServerClosed {
logger.Error("[Server] Fatal server error: %s", err)
return err
}
} else {
logger.Info("[Server] Not using TLS")
logger.Info("[Server] HTTP server is going live on port %d!", s.config.Port)
err := s.http.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
logger.Error("[Server] Fatal server error: %s", err)
return err
}
}
return nil
}
// Stops the server immediately
func (s *Server) Stop() {
ctx, cfunc := context.WithDeadline(context.Background(), time.Now().Add(time.Second*10))
s.http.Shutdown(ctx)
cfunc()
}

86
src/server/validation.go

@ -0,0 +1,86 @@
/*
dela - web TODO list
Copyright (C) 2023 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 server
import (
"Unbewohnte/dela/db"
"net/http"
)
const (
MinimalLoginLength uint = 3
MinimalUsernameLength uint = 3
MinimalPasswordLength uint = 5
)
// Check if user is valid. Returns false and a reason-string if not
func IsUserValid(user db.User) (bool, string) {
if uint(len(user.Login)) < MinimalLoginLength {
return false, "Login is too small"
}
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!
return false, "Password has a non printable ASCII character"
}
}
return true, ""
}
// Checks if such a user exists and compares passwords. Returns true if such user exists and passwords do match
func IsUserAuthorized(db *db.DB, user db.User) bool {
userDB, err := db.GetUser(user.Login)
if err != nil {
return false
}
if userDB.Password != user.Password {
return false
}
return true
}
/*
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
}
return IsUserAuthorized(dbase, db.User{
Login: login,
Password: password,
})
}
func GetLoginFromAuth(req *http.Request) string {
login, _, _ := req.BasicAuth()
return login
}

4085
static/bootstrap/css/bootstrap-grid.css vendored

File diff suppressed because it is too large Load Diff

1
static/bootstrap/css/bootstrap-grid.css.map

File diff suppressed because one or more lines are too long

6
static/bootstrap/css/bootstrap-grid.min.css vendored

File diff suppressed because one or more lines are too long

1
static/bootstrap/css/bootstrap-grid.min.css.map

File diff suppressed because one or more lines are too long

4084
static/bootstrap/css/bootstrap-grid.rtl.css

File diff suppressed because it is too large Load Diff

1
static/bootstrap/css/bootstrap-grid.rtl.css.map

File diff suppressed because one or more lines are too long

6
static/bootstrap/css/bootstrap-grid.rtl.min.css vendored

File diff suppressed because one or more lines are too long

1
static/bootstrap/css/bootstrap-grid.rtl.min.css.map

File diff suppressed because one or more lines are too long

593
static/bootstrap/css/bootstrap-reboot.css vendored

@ -0,0 +1,593 @@
/*!
* Bootstrap Reboot v5.3.0 (https://getbootstrap.com/)
* Copyright 2011-2023 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #adb5bd;
--bs-body-color-rgb: 173, 181, 189;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(173, 181, 189, 0.75);
--bs-secondary-color-rgb: 173, 181, 189;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(173, 181, 189, 0.5);
--bs-tertiary-color-rgb: 173, 181, 189;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-left: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: left;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: left;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: left;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
outline-offset: -2px;
-webkit-appearance: textfield;
}
/* rtl:raw:
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */

1
static/bootstrap/css/bootstrap-reboot.css.map

File diff suppressed because one or more lines are too long

6
static/bootstrap/css/bootstrap-reboot.min.css vendored

File diff suppressed because one or more lines are too long

1
static/bootstrap/css/bootstrap-reboot.min.css.map

File diff suppressed because one or more lines are too long

590
static/bootstrap/css/bootstrap-reboot.rtl.css

@ -0,0 +1,590 @@
/*!
* Bootstrap Reboot v5.3.0 (https://getbootstrap.com/)
* Copyright 2011-2023 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #adb5bd;
--bs-body-color-rgb: 173, 181, 189;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(173, 181, 189, 0.75);
--bs-secondary-color-rgb: 173, 181, 189;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(173, 181, 189, 0.5);
--bs-tertiary-color-rgb: 173, 181, 189;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-right: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-right: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: right;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: right;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: right;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
outline-offset: -2px;
-webkit-appearance: textfield;
}
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */

1
static/bootstrap/css/bootstrap-reboot.rtl.css.map

File diff suppressed because one or more lines are too long

6
static/bootstrap/css/bootstrap-reboot.rtl.min.css vendored

File diff suppressed because one or more lines are too long

1
static/bootstrap/css/bootstrap-reboot.rtl.min.css.map

File diff suppressed because one or more lines are too long

5399
static/bootstrap/css/bootstrap-utilities.css vendored

File diff suppressed because it is too large Load Diff

1
static/bootstrap/css/bootstrap-utilities.css.map

File diff suppressed because one or more lines are too long

6
static/bootstrap/css/bootstrap-utilities.min.css vendored

File diff suppressed because one or more lines are too long

1
static/bootstrap/css/bootstrap-utilities.min.css.map

File diff suppressed because one or more lines are too long

5390
static/bootstrap/css/bootstrap-utilities.rtl.css

File diff suppressed because it is too large Load Diff

1
static/bootstrap/css/bootstrap-utilities.rtl.css.map

File diff suppressed because one or more lines are too long

6
static/bootstrap/css/bootstrap-utilities.rtl.min.css vendored

File diff suppressed because one or more lines are too long

1
static/bootstrap/css/bootstrap-utilities.rtl.min.css.map

File diff suppressed because one or more lines are too long

12071
static/bootstrap/css/bootstrap.css vendored

File diff suppressed because it is too large Load Diff

1
static/bootstrap/css/bootstrap.css.map

File diff suppressed because one or more lines are too long

6
static/bootstrap/css/bootstrap.min.css vendored

File diff suppressed because one or more lines are too long

1
static/bootstrap/css/bootstrap.min.css.map

File diff suppressed because one or more lines are too long

12035
static/bootstrap/css/bootstrap.rtl.css

File diff suppressed because it is too large Load Diff

1
static/bootstrap/css/bootstrap.rtl.css.map

File diff suppressed because one or more lines are too long

6
static/bootstrap/css/bootstrap.rtl.min.css vendored

File diff suppressed because one or more lines are too long

1
static/bootstrap/css/bootstrap.rtl.min.css.map

File diff suppressed because one or more lines are too long

6306
static/bootstrap/js/bootstrap.bundle.js

File diff suppressed because it is too large Load Diff

1
static/bootstrap/js/bootstrap.bundle.js.map

File diff suppressed because one or more lines are too long

7
static/bootstrap/js/bootstrap.bundle.min.js vendored

File diff suppressed because one or more lines are too long

1
static/bootstrap/js/bootstrap.bundle.min.js.map

File diff suppressed because one or more lines are too long

4439
static/bootstrap/js/bootstrap.esm.js

File diff suppressed because it is too large Load Diff

1
static/bootstrap/js/bootstrap.esm.js.map

File diff suppressed because one or more lines are too long

7
static/bootstrap/js/bootstrap.esm.min.js vendored

File diff suppressed because one or more lines are too long

1
static/bootstrap/js/bootstrap.esm.min.js.map

File diff suppressed because one or more lines are too long

4486
static/bootstrap/js/bootstrap.js vendored

File diff suppressed because it is too large Load Diff

1
static/bootstrap/js/bootstrap.js.map

File diff suppressed because one or more lines are too long

7
static/bootstrap/js/bootstrap.min.js vendored

File diff suppressed because one or more lines are too long

1
static/bootstrap/js/bootstrap.min.js.map

File diff suppressed because one or more lines are too long

BIN
static/favicon.ico

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
static/images/android-chrome-192x192.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

BIN
static/images/android-chrome-512x512.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
static/images/apple-touch-icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

3
static/images/check.svg

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16">
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
</svg>

After

Width:  |  Height:  |  Size: 295 B

3
static/images/door-open-fill.svg

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-door-open-fill" viewBox="0 0 16 16">
<path d="M1.5 15a.5.5 0 0 0 0 1h13a.5.5 0 0 0 0-1H13V2.5A1.5 1.5 0 0 0 11.5 1H11V.5a.5.5 0 0 0-.57-.495l-7 1A.5.5 0 0 0 3 1.5V15H1.5zM11 2h.5a.5.5 0 0 1 .5.5V15h-1V2zm-2.5 8c-.276 0-.5-.448-.5-1s.224-1 .5-1 .5.448.5 1-.224 1-.5 1z"/>
</svg>

After

Width:  |  Height:  |  Size: 375 B

BIN
static/images/favicon-16x16.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 775 B

BIN
static/images/favicon-32x32.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
static/images/favicon.ico

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

4
static/images/person-dash-fill.svg

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-person-dash-fill" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M11 7.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 1-.5-.5z"/>
<path d="M1 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H1zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/>
</svg>

After

Width:  |  Height:  |  Size: 327 B

4
static/images/person-fill-add.svg

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-person-fill-add" viewBox="0 0 16 16">
<path d="M12.5 16a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm.5-5v1h1a.5.5 0 0 1 0 1h-1v1a.5.5 0 0 1-1 0v-1h-1a.5.5 0 0 1 0-1h1v-1a.5.5 0 0 1 1 0Zm-2-6a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
<path d="M2 13c0 1 1 1 1 1h5.256A4.493 4.493 0 0 1 8 12.5a4.49 4.49 0 0 1 1.544-3.393C9.077 9.038 8.564 9 8 9c-5 0-6 3-6 4Z"/>
</svg>

After

Width:  |  Height:  |  Size: 449 B

3
static/images/trash3-fill.svg

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash3-fill" viewBox="0 0 16 16">
<path d="M11 1.5v1h3.5a.5.5 0 0 1 0 1h-.538l-.853 10.66A2 2 0 0 1 11.115 16h-6.23a2 2 0 0 1-1.994-1.84L2.038 3.5H1.5a.5.5 0 0 1 0-1H5v-1A1.5 1.5 0 0 1 6.5 0h3A1.5 1.5 0 0 1 11 1.5Zm-5 0v1h4v-1a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 0-.5.5ZM4.5 5.029l.5 8.5a.5.5 0 1 0 .998-.06l-.5-8.5a.5.5 0 1 0-.998.06Zm6.53-.528a.5.5 0 0 0-.528.47l-.5 8.5a.5.5 0 0 0 .998.058l.5-8.5a.5.5 0 0 0-.47-.528ZM8 4.5a.5.5 0 0 0-.5.5v8.5a.5.5 0 0 0 1 0V5a.5.5 0 0 0-.5-.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 582 B

3
static/images/x.svg

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-x" viewBox="0 0 16 16">
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
</svg>

After

Width:  |  Height:  |  Size: 332 B

Loading…
Cancel
Save