Compare commits

..

No commits in common. '44f325cf6097726932b42c4f1f6db666a04bb777' and '3a0ab781f7853d1638ebe07b2a5f515b49a4e402' have entirely different histories.

  1. 4
      .gitignore
  2. 26
      LICENSE.modernc.org.sqlite
  3. 54
      Makefile
  4. 65
      README.md
  5. BIN
      TZ.docx
  6. 28
      pages/about.html
  7. 88
      pages/base.html
  8. 343
      pages/category.html
  9. 13
      pages/error.html
  10. 217
      pages/index.html
  11. 68
      pages/login.html
  12. 99
      pages/paint.html
  13. 124
      pages/register.html
  14. 116
      scripts/api.js
  15. 51
      scripts/auth.js
  16. 33
      src/conf/conf.go
  17. 29
      src/db/db.go
  18. 10
      src/db/db_test.go
  19. 186
      src/db/group.go
  20. 168
      src/db/todo.go
  21. 76
      src/db/user.go
  22. 132
      src/db/verification.go
  23. 15
      src/email/auth.go
  24. 52
      src/email/email.go
  25. 43
      src/encryption/encryption.go
  26. 4
      src/main.go
  27. 16
      src/misc/codeNumeric.go
  28. 455
      src/server/api.go
  29. 15
      src/server/api_test.go
  30. 140
      src/server/auth.go
  31. 772
      src/server/endpoints.go
  32. 45
      src/server/page.go
  33. 155
      src/server/server.go
  34. 117
      src/server/validation.go
  35. 202
      static/fonts/LICENSE.txt
  36. BIN
      static/fonts/Roboto-Black.ttf
  37. BIN
      static/fonts/Roboto-BlackItalic.ttf
  38. BIN
      static/fonts/Roboto-Bold.ttf
  39. BIN
      static/fonts/Roboto-BoldItalic.ttf
  40. BIN
      static/fonts/Roboto-Italic.ttf
  41. BIN
      static/fonts/Roboto-Light.ttf
  42. BIN
      static/fonts/Roboto-LightItalic.ttf
  43. BIN
      static/fonts/Roboto-Medium.ttf
  44. BIN
      static/fonts/Roboto-MediumItalic.ttf
  45. BIN
      static/fonts/Roboto-Regular.ttf
  46. BIN
      static/fonts/Roboto-Thin.ttf
  47. BIN
      static/fonts/Roboto-ThinItalic.ttf
  48. 3
      static/images/arrows-fullscreen.svg
  49. 4
      static/images/box-arrow-up-right.svg
  50. 3
      static/images/brightness-high.svg
  51. BIN
      static/images/dela_main.png
  52. 4
      static/images/emoji-frown.svg
  53. 4
      static/images/envelope-at.svg
  54. 4
      static/images/info-circle.svg
  55. 4
      static/images/key.svg
  56. 4
      static/images/moon-stars.svg
  57. 3
      static/images/universal-access.svg

4
.gitignore vendored

@ -1,4 +1,4 @@
dela.zip
bin/
TODO
dela.db
dela.zip
conf.json

26
LICENSE.modernc.org.sqlite

@ -1,26 +0,0 @@
Copyright (c) 2017 The Sqlite Authors. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

54
Makefile

@ -1,4 +1,4 @@
all:
all: savedb clean
mkdir -p bin && \
cd src && CGO_ENABLED=0 go build && mv dela ../bin && \
cd .. && \
@ -6,41 +6,57 @@ all:
cp -r scripts bin && \
cp -r static bin
-mv dela.db bin/
portable: clean all
cd bin/ && cp ../COPYING . && cp ../README.md . && zip -r dela.zip * && mv dela.zip ..
savedb:
-cp bin/dela.db .
cross: clean
mkdir -p bin
mkdir -p bin/dela_linux_x64
mkdir -p bin/dela_linux_x32
cp -r pages bin/dela_linux_x64
cp -r scripts bin/dela_linux_x64
cp -r static bin/dela_linux_x64
cp COPYING bin/dela_linux_x64
cp README.md bin/dela_linux_x64
mkdir -p bin/dela_windows_x64
mkdir -p bin/dela_windows_arm64
cp -r pages bin/dela_windows_x64
cp -r scripts bin/dela_windows_x64
cp -r static bin/dela_windows_x64
cp COPYING bin/dela_windows_x64
cp README.md bin/dela_windows_x64
mkdir -p bin/dela_darwin_x64
cp -r pages bin/dela_darwin_x64
cp -r scripts bin/dela_darwin_x64
cp -r static bin/dela_darwin_x64
cp COPYING bin/dela_darwin_x64
cp README.md bin/dela_darwin_x64
mkdir -p bin/dela_darwin_arm64
mkdir -p bin/dela_freebsd_x64
mkdir -p bin/dela_freebsd_arm64
cp -r pages bin/dela_darwin_arm64
cp -r scripts bin/dela_darwin_arm64
cp -r static bin/dela_darwin_arm64
cp COPYING bin/dela_darwin_arm64
cp README.md bin/dela_darwin_arm64
mkdir -p bin/dela_freebsd_x64
cp -r pages bin/dela_freebsd_x64
cp -r scripts bin/dela_freebsd_x64
cp -r static bin/dela_freebsd_x64
cp COPYING bin/dela_freebsd_x64
cp README.md bin/dela_freebsd_x64
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

65
README.md

@ -1,31 +1,80 @@
# dela (pun интедед) - daily events list application, aka web TODO list
![Dela](static/images/android-chrome-192x192.png "Dela logo")
## About
# Dela - dead simple web TODO list
dela is a web TODO list application which can be hosted on your server and accessed via browser. Current capabilities include:
## 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
- TODO deletion
- TODO drag-and-drop on categories
- Due date selection on TODO creation
## Build
Dela is written in Go, so you need to have a Go compiler.
For ease of compilation it is also recommended to have `make`.
To automatically compile Dela and have a ready-to-go solution, run:
```
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).
a portable `/bin` directory should appear where binary and base contents are located.
### Manual build
Or you can manually compile with `go build` inside the `src` directory. Move the binary and put it alongside `pages`, `scripts` and `static` (or specify the path to the contents directory via configuration file which is created automatically upon first launch).
### Portable build
To get a portable archive run:
```
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)
which will create `dela.zip` archive with all the content directories and the binary which can be freely transported to other machines (of the same OS and architecture)
### Cross compilation
For cross-platform compilation there is:
```
make cross
```
which cross compiles the project for linux, freebsd, windows and darwin systems. Portable solutions will be put in `/bin` directory in the corresponding subdirectories.
## 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.
### Configuration file
After the first run a configuration file will be put alongside the executable, upon the second launch the server will start and the service will be accessible on the specified port.
## License
Currently configuration file contains these filelds:
```json
{
"port": 8080,
"cert_file_path": "",
"key_file_path": "",
"base_content_dir": ".",
"production_db_name": "dela.db"
}
```
AGPLv3
| Field | Description |
| --- | ----------- |
| port | port on which the service will run |
| cert_file_path | path to the SSL certificate file |
| key_file_path | path to the SSL certificate key file |
| base_content_dir | path to the directory with `pages`, `scripts` and `static` subdirectories |
| production_db_name | SQLite3 database file path |
### SSL certificates
If you intend to use SSL certificates - there are corresponding fields in the configuration file.
## License
Dela is licensed under AGPL

BIN
TZ.docx

Binary file not shown.

28
pages/about.html

@ -1,15 +1,23 @@
{{ template "base" . }}
{{ define "content" }}
<div class="cover-container d-flex w-100 h-100 p-3 mx-auto flex-column">
<main class="px-3">
<h1>Dela.</h1>
<p class="lead">A free and open-source 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>
</main>
</div>
<main class="container my-5">
<div class="px-4 pt-5 my-5 text-center border shadow-lg">
<h1 class="display-4 fw-bold text-body-emphasis">Dela</h1>
<div class="col-lg-6 mx-auto">
<p class="lead mb-4">a dead simple and minimalistic web TODO list</p>
<div class="d-grid gap-2 d-sm-flex justify-content-sm-center mb-5">
<a href="/register" class="btn btn-primary btn-lg px-4 me-md-2 fw-bold">Register</a>
<a href="/login" class="btn btn-outline-secondary btn-lg px-4">Log in</a>
</div>
</div>
<div class="overflow-hidden" style="max-height: 30vh;">
<div class="container px-5">
<img src="/static/images/dela_main.png" class="img-fluid border rounded-3 shadow-lg mb-4" alt="Dela interface" width="980" height="700" loading="lazy">
</div>
</div>
</div>
</main>
{{ end }}

88
pages/base.html

@ -1,5 +1,6 @@
{{ define "base" }}
<!DOCTYPE html>
<html lang="en">
@ -10,62 +11,95 @@
<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>
<style>
html * {
font-family: "Roboto" !important;
src: url("/static/fonts/Roboto-Regular.ttf");
}
</style>
</head>
<body class="d-flex flex-column h-100">
<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">
<body class="w-100 h-100">
<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>
</div>
</a>
<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
<li><a href="/" class="nav-link px-2 text-white">Main</a></li>
<li><a href="/about" class="nav-link px-2 text-white">About</a></li>
</ul>
<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 class="text-end p-3">
<button id="theme-switch-btn" class="btn btn-secondary" onclick="toggleTheme();">
<img id="theme-svg" src="/static/images/brightness-high.svg" alt="Change theme">
</button>
</div>
<div class="text-end" id="bar-auth">
<a href="/login" class="btn btn-outline-light me-2">Login</a>
<a href="/register" class="btn btn-warning">Sign-up</a>
</div>
</div>
</div>
</div>
</header>
<div style="margin: auto;
margin-top: 5ch;
margin-bottom: 10ch;
max-width: 120ch;">
<!-- Content -->
{{ template "content" . }}
</div>
</body>
</body>
</html>
<script src="/scripts/auth.js"></script>
<script src="/scripts/api.js"></script>
<script>
document.addEventListener('DOMContentLoaded', async function() {
let username = getUsername();
let password = getUserPassword();
function toggleTheme() {
if (document.documentElement.getAttribute('data-bs-theme') == 'dark') {
document.documentElement.setAttribute('data-bs-theme','light');
localStorage.setItem("theme", "light");
document.getElementById("theme-svg").src = "/static/images/brightness-high.svg";
} else {
document.documentElement.setAttribute('data-bs-theme','dark');
localStorage.setItem("theme", "dark");
document.getElementById("theme-svg").src = "/static/images/moon-stars.svg";
}
}
if (username == null | username == "" | password == null | password == "") {
if (window.location.pathname != "/about" && window.location.pathname != "/login" && window.location.pathname != "/register") {
window.location.replace("/about");
document.addEventListener('DOMContentLoaded', async function() {
// Theme
let theme = localStorage.getItem("theme");
if (theme) {
document.documentElement.setAttribute('data-bs-theme', theme);
if (theme == "dark") {
document.getElementById("theme-svg").src = "/static/images/moon-stars.svg";
} else {
document.getElementById("theme-svg").src = "/static/images/brightness-high.svg";
}
return;
}
// Check if auth info is indeed valid
let response = await getUser(username, password);
// Check if auth info is valid
try {
let response = await getUser();
if (response.ok) {
let barAuth = document.getElementById("bar-auth");
barAuth.innerHTML = "<b>" + username + "</b>" + " | ";
barAuth.innerHTML += '<button id="log-out-btn" class="btn btn-outline-primary"><img src="/static/images/person-dash-fill.svg"></button>';
barAuth.innerHTML = '<button id="log-out-btn" class="btn btn-outline-light me-2"><img src="/static/images/person-dash-fill.svg"></button>';
document.getElementById("log-out-btn").addEventListener("click", (event) => {
// Log out
forgetAuthInfo();
window.location.replace("/about");
});
} else {
}
} catch(error) {
forgetAuthInfo();
window.location.replace("/about");
return;
}
}, false)
}, false)
</script>
{{ end }}

343
pages/category.html

@ -0,0 +1,343 @@
{{ template "base" . }}
{{ define "content" }}
<h1 style="display: none;" id="categoryId">{{.CurrentGroupId}}</h1>
<!-- Main -->
<main class="d-flex flex-wrap">
<!-- MODALS -->
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalLabel">Confirm Deletion</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Are you sure you want to delete this ToDo?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger" id="confirmDeleteButton">Delete</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</div>
</div>
<!-- End delete confirmation modal -->
<!-- Paint Canvas Modal -->
<div class="modal fade" id="paintModal" tabindex="-1" aria-labelledby="paintModal" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="paintModal">Draw Note</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" onclick="clearCanvas();"></button>
</div>
<div class="modal-body w-100 d-flex flex-column justify-content-center align-items-center">
{{ template "paint" . }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" id="saveCanvasButton" data-bs-dismiss="modal">Save</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" onclick="clearCanvas();">Cancel</button>
</div>
</div>
</div>
</div>
<!-- End Paint Canvas Modal -->
<!-- ToDo display Modal -->
<div class="modal fade" id="todoModal" tabindex="-1" aria-labelledby="todoModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="todoModalLabel">TODO Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p><strong>Text:</strong> <span id="modalTodoText"></span></p>
<p><strong>Created:</strong> <span id="modalTodoCreated"></span></p>
<p><strong>Due:</strong> <span id="modalTodoDue"></span></p>
<p><strong>Completion time:</strong> <span id="modalTodoCompletionTime"></span></p>
<img id="modalTodoImage" src="" class="img-fluid" style="display: none;">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div id="sidebar" class="col border-right shadow-lg 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">
<img class="bi pe-none me-2" width="30" height="24" src="/static/images/arrows-fullscreen.svg">
<span class="fs-5 fw-semibold">Categories</span>
</a>
<div class="list-group list-group-flush border-bottom scrollarea">
{{ range .Groups }}
<a id="group-{{.ID}}" href="/group/{{.ID}}" class="list-group-item list-group-item-action py-3 lh-sm {{if eq .ID $.CurrentGroupId}} active {{end}}" aria-current="true" ondragover="allowDrop(event);" ondrop="drop(event);">
<div id="group-{{.ID}}" class="d-flex w-100 align-items-center justify-content-between">
<strong id="group-{{.ID}}" class="mb-1">{{ .Name }}</strong>
<small id="group-{{.ID}}">{{ .TimeCreated }}</small>
</div>
{{ if not .Removable }}
<div id="group-{{.ID}}" class="col-10 mb-1 small">Not removable</div>
{{ end }}
</a>
{{ end }}
</div>
</div>
<!-- Main ToDos section -->
<div class="p-2 flex-grow-1">
<form action="javascript:void(0);" id="todoForm">
<div class="row g-3 align-items-center">
<div class="col-md">
<label for="newTodoText" class="form-label">TODO Text</label>
<input type="text" class="form-control" id="newTodoText" placeholder="Enter TODO text" required>
</div>
<div class="col-md">
<label for="newTodoDue" class="form-label">Due Date</label>
<input type="date" class="form-control" name="newTodoDue" id="newTodoDue" required>
</div>
<div class="col-auto">
<button type="button" class="btn btn-primary" id="newTodoPaint" onclick="openPaintModal();">Paint</button>
</div>
<div class="col-auto">
<button type="submit" id="newTodoSubmit" class="btn btn-primary">Add</button>
<button type="button" id="show-done" class="btn btn-secondary">Show Done</button>
</div>
</div>
</form>
<div class="container text-center">
<!-- Due -->
<table class="table table-hover" id="due-todos">
<thead>
<th>Image</th>
<th>ToDo</th>
<th>Created</th>
<th>Due</th>
</thead>
<tbody class="text-break">
{{ range .Todos }}
{{ if not .IsDone }}
<tr onclick="openTodoModal('{{.ID}}', '{{.Text}}', '{{.TimeCreated}}', '{{.Due}}', null, '{{ printf "%s" .Image }}');" draggable="true" id="todo-{{.ID}}" ondragstart="dragStart(event);">
{{ if not .Image }}
<!-- Display transparent white pixel -->
<td><img class="todo-image" src='data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' width="64px" height="64px"></td>
{{ else }}
<td><img class="todo-image" src='{{ printf "%s" .Image }}' width="64px" height="64px"></td>
{{ end }}
<td class="todo-text text-wrap text-break">{{ .Text }}</td>
<td class="todo-created">{{ .TimeCreated }}</td>
<td class="todo-due">{{ .Due }}</td>
<td class="todo-due-unix" style="display: none;">{{ .DueUnix }}</td>
<td>
<button class="btn btn-success" onclick="markAsDoneRefresh('{{.ID}}');">
<img src='/static/images/check.svg'>
</button>
<button class="btn btn-danger" onclick="openDeleteModal('{{.ID}}');">
<img src='/static/images/trash3-fill.svg'>
</button>
</td>
</tr>
{{ end }}
{{ end }}
</tbody>
</table>
<!-- Completed -->
<table class="table table-hover" style="display: none;" id="completed-todos">
<thead>
<th>Image</th>
<th>ToDo</th>
<th>Created</th>
<th>Completed</th>
</thead>
<tbody class="text-break">
{{ range .Todos }}
{{ if .IsDone }}
<tr onclick="openTodoModal('{{.ID}}', '{{.Text}}', '{{.TimeCreated}}', '{{.Due}}', '{{.CompletionTime}}', '{{ printf "%s" .Image }}');">
{{ if not .Image }}
<!-- Display transparent white pixel -->
<td><img src='data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' width="64px" height="64px"></td>
{{ else }}
<td><img src='{{ printf "%s" .Image }}' width="64px" height="64px"></td>
{{ end }}
<td>{{ .Text }}</td>
<td>{{ .TimeCreated }}</td>
<td>{{ .CompletionTime }}</td>
<td>
<button class="btn btn-danger" onclick="deleteTodoRefresh('{{.ID}}');"><img src='/static/images/trash3-fill.svg'></button>
</td>
</tr>
{{ end }}
{{ end }}
</tbody>
</table>
</div>
</div>
</main>
<script>
let todoToDeleteId;
// TODO deletion modal functions
function openDeleteModal(id) {
todoToDeleteId = id; // Save ID for handleTodoDelete to work properly
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'), {});
// Remove any existing event listener to prevent multiple bindings
const confirmDeleteButton = document.getElementById('confirmDeleteButton');
confirmDeleteButton.removeEventListener('click', handleTodoDelete);
// Add the event listener for the delete action
confirmDeleteButton.addEventListener('click', handleTodoDelete);
deleteModal.show();
}
async function handleTodoDelete() {
await deleteTodoRefresh(todoToDeleteId); // Call the delete function
const deleteModal = bootstrap.Modal.getInstance(document.getElementById('deleteModal'));
deleteModal.hide(); // Hide the modal
}
function openPaintModal() {
const paintModal = new bootstrap.Modal(document.getElementById('paintModal'), {});;
paintModal.show();
}
// Mark TODO as done
async function markAsDoneRefresh(id) {
await markAsDone(id);
window.location.reload();
}
// Delete TODO with ID
async function deleteTodoRefresh(id) {
await deleteTodo(id);
window.location.reload();
}
async function showDone() {
// Hide not done, show done
let completedTodos = document.getElementById("completed-todos");
completedTodos.style.display = "table";
let dueTodos = document.getElementById("due-todos");
dueTodos.style.display = "none";
}
function allowDrop(event) {
event.preventDefault();
}
function dragStart(event) {
event.dataTransfer.setData("text", event.target.id);
event.dataTransfer.effectAllowed = "move";
}
async function drop(event) {
event.preventDefault();
var todoPageId = event.dataTransfer.getData("text");
let draggedTodo = document.getElementById(todoPageId);
let todoId = todoPageId.split("-")[1];
let targetGroupId = event.target.id.split("-")[1];
if (targetGroupId == document.getElementById("categoryId").innerText) {
// Do nothing
return;
}
// Update todo's group ID
let result = await updateTodo(todoId, {
text: draggedTodo.getElementsByClassName("todo-text")[0].innerText,
groupId: Number(targetGroupId),
dueUnix: Number(draggedTodo.getElementsByClassName("todo-due-unix")[0].innerText),
image: Array.from(draggedTodo.getElementsByClassName("todo-image")[0].src, char => char.charCodeAt(0))
});
window.location.reload();
}
function openTodoModal(id, text, created, due, completionTime, image) {
document.getElementById('modalTodoText').innerText = text;
document.getElementById('modalTodoCreated').innerText = created;
document.getElementById('modalTodoDue').innerText = due;
document.getElementById('modalTodoCompletionTime').innerText = completionTime;
let img = document.getElementById('modalTodoImage');
if (img) {
img.src = image;
img.style.display = 'block';
} else {
img.style.display = 'none';
}
const todoModal = new bootstrap.Modal(document.getElementById('todoModal'));
todoModal.show();
}
document.addEventListener('DOMContentLoaded', async function() {
document.getElementById("newTodoText").focus();
let showDoneButton = document.getElementById("show-done");
showDoneButton.addEventListener("click", (event) => {
// Rename the button
showDoneButton.innerText = "Show To Do";
showDoneButton.className = "btn btn-success";
// Show done
showDone();
// Make it "reset to default"
showDoneButton.addEventListener("click", (event) => {
location.reload();
});
});
// "Add" button
document.getElementById("newTodoSubmit").addEventListener("click", async (event) => {
let newTodoTextInput = document.getElementById("newTodoText");
let newTodoText = newTodoTextInput.value;
if (newTodoText.length < 1) {
newTodoTextInput.setCustomValidity("At least one character is needed!");
return;
} else {
newTodoTextInput.setCustomValidity("");
}
newTodoTextInput.value = "";
let newTodoDueInput = document.getElementById("newTodoDue");
let dueTimeStamp = Date.parse(newTodoDueInput.value) / 1000;
let groupId = document.getElementById("categoryId").innerText;
let canvasImage = getCanvasImage();
if (canvasImage) {
canvasImage = Array.from(canvasImage, char => char.charCodeAt(0));
}
// Make a request
let response = await postNewTodo(
{text: newTodoText, groupId: Number(groupId), dueUnix: Number(dueTimeStamp), image: canvasImage}
);
if (response.ok) {
location.reload();
}
});
});
</script>
{{ end }}

13
pages/error.html

@ -0,0 +1,13 @@
{{ 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 class="text-danger display-2">Error!</h1>
<img src="/static/images/emoji-frown.svg" alt="Sad face" width="128px" >
<p>Sorry! Something went wrong somewhere!</p>
<p><u>Try to reload the faulty page or try again later</u></p>
</div>
</main>
{{ end }}

217
pages/index.html

@ -3,167 +3,92 @@
{{ define "content" }}
<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">
<!-- Main -->
<main>
<div class="d-flex flex-wrap">
<!-- Sidebar -->
<div id="sidebar" class="col border-right shadow-lg flex-shrink-1 p-2 d-flex flex-column align-items-stretch bg-body-tertiary">
<a href="/" class="d-flex align-items-center flex-shrink-0 p-3 link-body-emphasis text-decoration-none border-bottom">
<img class="bi pe-none me-2" width="30" height="24" src="/static/images/arrows-fullscreen.svg">
<span class="fs-5 fw-semibold">Categories</span>
</a>
<div class="list-group list-group-flush border-bottom scrollarea">
{{ range .Groups }}
<a href="/group/{{.ID}}" 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">{{ .Name }}</strong>
<small>{{ .TimeCreated }}</small>
</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>
{{ if not .Removable }}
<div class="col-10 mb-1 small">Not removable</div>
{{ end }}
</a>
{{ end }}
</div>
<div class="input-group mb-3 py-md-5">
<input type="text" name="newCategory"aria-label="Category Name" aria-describedby="button-new-category" class="form-control" id="new-category-input" placeholder="Category Name">
<button id="button-new-category" onclick="createNewCategory();" class="btn btn-primary">Create</button >
</div>
</div>
</form>
<div class="container text-center" style="margin-top: 4ch;">
<table id="todos" class="table table-hover" style="word-wrap: break-word;"></table>
</div>
<!-- Groups -->
<div class="d-flex flex-column flex-grow-1 flex-md-row p-4 gap-4 py-md-5">
<div class="list-group flex-grow-1">
{{ range .Groups }}
<div class="list-group-item list-group-item-action d-flex gap-3 py-3" aria-current="true">
<a href="/group/{{.ID}}" class="list-group-item list-group-item-action d-flex gap-3 py-3">
<img src="/static/images/box-arrow-up-right.svg" alt="Go to this category" width="32" height="32">
<div class="d-flex gap-2 w-100 justify-content-between">
<div>
<h6 class="mb-0">{{ .Name }}</h6>
<p class="mb-0 opacity-75">Jump here</p>
</div>
<small class="opacity-50 text-nowrap">{{ .TimeCreated }}</small>
</div>
</a>
{{ if .Removable }}
<div class="small">
<button class="btn btn-danger" onclick="deleteCategoryRefresh('{{.ID}}')">
<img src="/static/images/trash3-fill.svg" alt="Remove category">
</button>
</div>
{{ end }}
</div>
{{ end }}
</div>
</div>
</div>
</main>
<script>
async function displayTodos(showDone) {
let username = getUsername();
let password = getUserPassword();
// Fetch and display TODOs
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();
}
});
async function createNewCategory() {
let categoryInput = document.getElementById("new-category-input");
let newCategoryName = categoryInput.value;
if (newCategoryName.length < 1) {
categoryInput.setCustomValidity("At least one character is needed!");
return;
} else {
// Delete button
document.getElementById(elem.buttonDel).addEventListener("click", async (event) => {
response = await deleteTodo(username, password, elem.item.id);
if (response.ok) {
location.reload();
}
});
}
categoryInput.setCustomValidity("");
}
}
categoryInput.value = "";
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();
});
// Post new category and refresh
await postNewGroup({
Name: newCategoryName
});
// "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("");
window.location.reload();
}
newTodoTextInput.value = "";
// Make a request
let response = await postNewTodo(username, password, {text: newTodoText});
if (response.ok) {
location.reload();
async function deleteCategoryRefresh(id) {
await deleteCategory(id);
window.location.reload();
}
});
// Fetch and display TODOs
await displayTodos(false);
}, false)
</script>
</script>
{{ end }}

68
pages/login.html

@ -2,52 +2,62 @@
{{ define "content" }}
<h3>Log in</h3>
<form name="loginForm" onsubmit="return false;">
<p>
<label for="username" class="form-label">Username</label> <br>
<input type="text" name="username" minlength="3" required>
</p>
<main class="d-flex flex-wrap align-content-center align-items-center container my-5 flex-column">
<div class="p-2 flex-fill text-wrap text-center border shadow-lg">
<h3 class="h3 mb-3 fw-normal">Log in</h3>
<form onsubmit="return false;">
<div class="mb-3 input-group">
<img src="/static/images/envelope-at.svg" alt="Email" class="input-group-text">
<input
type="email"
class="form-control"
id="input-email"
aria-describedby="Email"
aria-label="email@example.com"
placeholder="email@example.com"
required
minlength="3">
</div>
<p>
<label for="password" class="form-label">Password</label> <br>
<input type="password" name="password" minlength="3" required>
</p>
<div class="mb-3 input-group">
<img src="/static/images/key.svg" alt="Password" class="input-group-text">
<input
type="password"
class="form-control"
id="input-password"
aria-describedby="Password"
aria-label="Password"
placeholder="Password"
required
minlength="3">
</div>
<p><span id="error_message" class="text-danger"></span></p>
<input type="submit" value="Log in" class="btn btn-primary" onclick="logIn()">
</form>
<p>
<input type="submit" value="Log in" class="btn btn-primary" onmouseup="logIn()">
</p>
</form>
</div>
</main>
<script>
async function logIn() {
let loginForm = document.forms["loginForm"];
let username = String(loginForm.elements["username"].value).trim();
if (username.length < 3) {
let emailInput = document.getElementById("input-email");
if (!emailInput.reportValidity()) {
return;
}
let email = String(emailInput.value).trim();
let password = String(loginForm.elements["password"].value);
if (password.length < 3) {
let passwordInput = document.getElementById("input-password");
if (!passwordInput.reportValidity()) {
return;
}
let password = String(passwordInput.value);
password = sha256(password);
// Check if auth info is indeed valid
let response = await fetch("/api/user", {
method: "GET",
headers: {
"EncryptedBase64": "false",
"Auth": username + "<-->" + password
},
});
let response = await doLogin({email: email, password: password});
if (response.ok) {
rememberAuthInfo(username, password);
window.location.replace("/");
} else {
document.getElementById("error_message").innerText = await response.text();

99
pages/paint.html

@ -0,0 +1,99 @@
{{ define "paint" }}
<canvas class="row border border-secondary" id="drawingCanvas" width="256" height="256"></canvas>
<input class="row border border-secondary" type="color" id="colorPicker" value="#000000" aria-label="Drawing color">
<script>
const canvas = document.getElementById('drawingCanvas');
const ctx = canvas.getContext('2d');
const colorPicker = document.getElementById('colorPicker');
let drawing = false;
function startDrawing(x, y) {
drawing = true;
ctx.beginPath();
ctx.moveTo(x, y);
};
function draw(x, y){
if (drawing) {
ctx.strokeStyle = colorPicker.value;
ctx.lineWidth = 5;
ctx.lineTo(x, y);
ctx.stroke();
}
};
function stopDrawing() {
drawing = false;
ctx.closePath();
};
function getMousePos(event) {
const rect = canvas.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top
};
};
function getTouchPos(event) {
const rect = canvas.getBoundingClientRect();
return {
x: event.touches[0].clientX - rect.left,
y: event.touches[0].clientY - rect.top
};
};
// Mouse events
canvas.addEventListener('mousedown', (e) => {
const pos = getMousePos(e);
startDrawing(pos.x, pos.y);
});
canvas.addEventListener('mousemove', (e) => {
const pos = getMousePos(e);
draw(pos.x, pos.y);
});
canvas.addEventListener('mouseup', stopDrawing);
canvas.addEventListener('mouseleave', stopDrawing);
// Touch events
canvas.addEventListener('touchstart', (e) => {
e.preventDefault(); // Prevent scrolling
const pos = getTouchPos(e);
startDrawing(pos.x, pos.y);
});
canvas.addEventListener('touchmove', (e) => {
e.preventDefault(); // Prevent scrolling
const pos = getTouchPos(e);
draw(pos.x, pos.y);
});
canvas.addEventListener('touchend', stopDrawing);
// Fills with white
function clearCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
function isCanvasEmpty() {
const pixels = new Uint32Array(
ctx.getImageData(0, 0, canvas.width, canvas.height).data.buffer
);
return !pixels.some(color => color !== 0);
}
function getCanvasImage() {
if (!isCanvasEmpty()) {
return canvas.toDataURL("image/png");
}
return null;
}
</script>
{{ end }}

124
pages/register.html

@ -2,56 +2,118 @@
{{ define "content" }}
<h3>Register</h3>
<form name="registerForm" onsubmit="return false;">
<p>
<label for="username" class="form-label">Username</label> <br>
<input type="text" name="username" minlength="3" required>
</p>
<p>
<label for="password" class="form-label">Password</label> <br>
<input type="password" name="password" minlength="3" required>
</p>
<main class="d-flex flex-wrap align-content-center container my-5 flex-column">
<div class="p-2 flex-fill text-wrap text-center border shadow-lg">
<h3 class="h3 mb-3 fw-normal">Register <span title="Passwords are hashed client-side, your information is protected">
<img src="/static/images/info-circle.svg" alt="Information"></span>
</h3>
<form onsubmit="return false;">
<div class="mb-3 input-group">
<img src="/static/images/envelope-at.svg" alt="Email" class="input-group-text">
<input
type="email"
class="form-control"
id="input-email"
aria-describedby="Email"
aria-label="login@example.com"
placeholder="login@example.com"
required
minlength="3">
</div>
<div class="mb-3 input-group">
<img src="/static/images/key.svg" alt="Password" class="input-group-text">
<input
type="password"
class="form-control"
id="input-password"
aria-describedby="Password"
aria-label="Password"
placeholder="Password"
required
minlength="3">
</div>
<p><span id="error_message" class="text-danger"></span></p>
<input type="submit" value="Register" class="btn btn-primary" onclick="register();">
</form>
</div>
</main>
<div class="modal fade" id="verificationModal" tabindex="-1" aria-labelledby="verificationModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="verificationModalLabel">Email Verification</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Enter the verification code sent to your email address</p>
<div class="mb-3">
<label for="verificationCode" class="form-label">Verification Code</label>
<input type="text" id="input-code" class="form-control" id="verificationCode" placeholder="Enter your code" required>
</div>
<p class="text-danger"><span id="error-message-modal"></span></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="verifyButton" onclick="verify();">Verify</button>
</div>
</div>
</div>
</div>
<p>
<input type="submit" value="Register" class="btn btn-primary" onmouseup="register();">
</p>
</form>
<script>
async function register() {
let registerForm = document.forms["registerForm"];
function showVerificationModal() {
const verificationModal = new bootstrap.Modal(document.getElementById('verificationModal'), {});;
verificationModal.show();
}
async function verify() {
let emailInput = document.getElementById("input-email");
let email = String(emailInput.value).trim();
let username = String(registerForm.elements["username"].value).trim();
if (username.length < 3) {
let codeInput = document.getElementById("input-code");
let code = String(codeInput.value).trim();
let response = await postEmailVerification(email, code);
if (response.ok) {
window.location.replace("/");
} else {
document.getElementById("error-message-modal").innerText = await response.text();
}
}
async function register() {
let emailInput = document.getElementById("input-email");
if (!emailInput.reportValidity()) {
return;
}
let email = String(emailInput.value).trim();
let password = String(registerForm.elements["password"].value);
if (password.length < 3) {
let passwordInput = document.getElementById("input-password");
if (!passwordInput.reportValidity()) {
return;
}
let password = String(passwordInput.value);
let passwordSHA256 = sha256(password);
let postData = {
username: username,
email: email,
password: passwordSHA256,
};
let response = await fetch("/api/user", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(postData),
});
let response = await postNewUser(postData);
if (response.ok) {
rememberAuthInfo(postData.username, postData.password);
let json = await response.json();
if (json.confirm_email) {
// Open email confirmation modal
showVerificationModal();
} else {
window.location.replace("/");
}
} else {
document.getElementById("error_message").innerText = await response.text();
}

116
scripts/api.js

@ -1,69 +1,95 @@
/*
2023 Kasyanov Nikolay Alexeyevich (Unbewohnte)
2024 Kasyanov Nikolay Alexeyevich (Unbewohnte)
*/
async function postNewTodo(username, password, new_todo) {
return fetch("/api/todo", {
async function post(url, json) {
return fetch(url, {
method: "POST",
credentials: "include",
headers: {
"EncryptedBase64": "false",
"Auth": username + "<-->" + password,
"Content-Type": "application/json",
},
body: JSON.stringify(new_todo),
});
body: JSON.stringify(json)
})
}
async function postEmailVerification(email, code) {
return post("/api/user/verify", {"email":email, "code":code});
}
async function getTodos(username, password) {
return fetch("/api/todo", {
method: "GET",
headers: {
"EncryptedBase64": "false",
"Auth": username + "<-->" + password
},
});
async function postNewTodo(newTodo) {
return post("/api/todo/create", newTodo)
}
async function postNewGroup(newGroup) {
return post("/api/group/create", newGroup)
}
async function postNewUser(newUser) {
return post("/api/user/create", newUser)
}
async function doLogin(userInformation) {
return post("/api/user/login", userInformation)
}
async function getTodoGroups(username, password) {
return fetch("/api/group", {
async function get(url) {
return fetch(url, {
method: "GET",
headers: {
"EncryptedBase64": "false",
"Auth": username + "<-->" + password
},
});
credentials: "include",
})
}
async function deleteTodo(username, password, id) {
return fetch("/api/todo/"+String(id), {
method: "DELETE",
headers: {
"EnctyptedBase64": "false",
"Auth": username + "<-->" + password,
},
});
async function getUser() {
return get("/api/user/get");
}
async function getTodos() {
return get("/api/todo/get");
}
async function getGroup() {
return get("/api/group/get");
}
async function updateTodo(username, password, id, updatedTodo) {
return fetch("/api/todo/"+String(id), {
async function getAllGroups() {
return get("/api/user/get");
}
async function del(url) {
return fetch(url, {
method: "POST",
credentials: "include",
headers: {
"EncryptedBase64": "false",
"Auth": username + "<-->" + password,
"Content-Type": "application/json",
},
body: JSON.stringify(updatedTodo),
});
})
}
async function getUser(username, password) {
return fetch("/api/user", {
method: "GET",
headers: {
"EncryptedBase64": "false",
"Auth": username + "<-->" + password
},
});
async function deleteTodo(id) {
return del("/api/todo/delete/"+id);
}
async function deleteCategory(id) {
return del("/api/group/delete/"+id);
}
async function update(url, json) {
return post(url, json);
}
async function updateTodo(id, updatedTodo) {
return update("/api/todo/update/"+id, updatedTodo);
}
async function markAsDone(id) {
return update("/api/todo/markdone/"+id);
}
async function updateGroup(id, updatedGroup) {
return update("/api/group/update/"+id, updatedGroup);
}
async function updateUser(updatedUser) {
return update("/api/user/update", updatedUser);
}

51
scripts/auth.js

@ -1,52 +1,17 @@
/*
2023 Kasyanov Nikolay Alexeyevich (Unbewohnte)
2024 Kasyanov Nikolay Alexeevich (Unbewohnte)
*/
const AuthHeaderKey = "Auth";
const AuthSeparator = "<-->" // username<-->password
const authStorageUsername = "username";
const authStoragePassword = "password";
function encodeStringBase64(string) {
return btoa(
encodeURIComponent(string).replace(/%([0-9A-F]{2})/g, function to_bytes(match, p) {
return String.fromCharCode('0x' + p);
})
);
}
// Returns properly constructed string containing authentication information
function authString(login, password) {
return String(login) + String(AuthSeparator) + String(password);
}
// Returns properly constructed string containing authentication information, encodes afterwards
function authStringEncoded(login, password) {
return encodeStringBase64(authString(login, password));
}
// Saves auth information to local storage
function rememberAuthInfo(username, password) {
localStorage.setItem(authStorageUsername, username);
localStorage.setItem(authStoragePassword, password);
}
// Retrieves user's login from local storage
function getUsername() {
return localStorage.getItem(authStorageUsername);
}
// Retrieves user's password from local storage
function getUserPassword() {
return localStorage.getItem(authStoragePassword);
function getCookie(name){
return document.cookie.split(';').some(c => {
return c.trim().startsWith(name + '=');
});
}
// Removes all auth information from local storage
function forgetAuthInfo() {
localStorage.removeItem(authStorageUsername);
localStorage.removeItem(authStoragePassword);
if(getCookie("auth")) {
document.cookie = "auth" + "=" + ";expires=Thu, 01 Jan 1970 00:00:01 GMT";
}
}
/**

33
src/conf/conf.go

@ -1,6 +1,6 @@
/*
dela - web TODO list
Copyright (C) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte)
Copyright (C) 2023, 2025 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
@ -24,10 +24,27 @@ import (
"os"
)
type Conf struct {
type ServerConf struct {
Port uint16 `json:"port"`
CertFilePath string `json:"cert_file_path"`
KeyFilePath string `json:"key_file_path"`
}
type EmailerConf struct {
User string `json:"user"`
Host string `json:"host"`
HostPort uint16 `json:"host_port"`
Password string `json:"password"`
}
type EmailVerificationConf struct {
VerifyEmails bool `json:"verify_emails"`
Emailer EmailerConf `json:"emailer"`
}
type Conf struct {
Server ServerConf `json:"server"`
Verification EmailVerificationConf `json:"verification"`
BaseContentDir string `json:"base_content_dir"`
ProdDBName string `json:"production_db_name"`
}
@ -35,9 +52,21 @@ type Conf struct {
// Creates a default server configuration
func Default() Conf {
return Conf{
Server: ServerConf{
Port: 8080,
CertFilePath: "",
KeyFilePath: "",
},
Verification: EmailVerificationConf{
VerifyEmails: true,
Emailer: EmailerConf{
User: "you@example.com",
Host: "smtp.example.com",
HostPort: 587,
Password: "hostpassword",
},
},
BaseContentDir: ".",
ProdDBName: "dela.db",
}

29
src/db/db.go

@ -1,6 +1,6 @@
/*
dela - web TODO list
Copyright (C) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte)
Copyright (C) 2023, 2024 Kasyanov Nikolay Alexeyevich (Unbewohnte)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
@ -33,9 +33,22 @@ type DB struct {
func setUpTables(db *DB) error {
// Users
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS users(
username TEXT PRIMARY KEY UNIQUE,
email TEXT PRIMARY KEY UNIQUE,
password TEXT NOT NULL,
time_created_unix INTEGER)`,
time_created_unix INTEGER,
confirmed_email INTEGER)`,
)
if err != nil {
return err
}
// User Email Verification
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS verifications(
id INTEGER PRIMARY KEY AUTOINCREMENT UNIQUE,
email TEXT NOT NULL,
code TEXT NOT NULL,
issued_unix INTEGER,
life_seconds INTEGER)`,
)
if err != nil {
return err
@ -46,8 +59,9 @@ func setUpTables(db *DB) error {
id INTEGER PRIMARY KEY AUTOINCREMENT UNIQUE,
name TEXT,
time_created_unix INTEGER,
owner_username TEXT NOT NULL,
FOREIGN KEY(owner_username) REFERENCES users(username))`,
owner_email TEXT NOT NULL,
removable INTEGER,
FOREIGN KEY(owner_email) REFERENCES users(email))`,
)
if err != nil {
return err
@ -60,11 +74,12 @@ func setUpTables(db *DB) error {
text TEXT NOT NULL,
time_created_unix INTEGER,
due_unix INTEGER,
owner_username TEXT NOT NULL,
owner_email TEXT NOT NULL,
is_done INTEGER,
completion_time_unix INTEGER,
image BLOB,
FOREIGN KEY(group_id) REFERENCES todo_groups(id),
FOREIGN KEY(owner_username) REFERENCES users(username))`,
FOREIGN KEY(owner_email) REFERENCES users(email))`,
)
if err != nil {
return err

10
src/db/db_test.go

@ -32,7 +32,7 @@ func TestApi(t *testing.T) {
// User
user := User{
Username: "user1",
Email: "user1@mail.ru",
Password: "ruohguoeruoger",
TimeCreatedUnix: 12421467,
}
@ -42,7 +42,7 @@ func TestApi(t *testing.T) {
t.Fatalf("failed to create user: %s", err)
}
dbUser, err := db.GetUser(user.Username)
dbUser, err := db.GetUser(user.Email)
if err != nil {
t.Fatalf("failed to retrieve created user: %s", err)
}
@ -55,7 +55,7 @@ func TestApi(t *testing.T) {
group := TodoGroup{
Name: "group1",
TimeCreatedUnix: 13524534,
OwnerUsername: user.Username,
OwnerEmail: user.Email,
}
err = db.CreateTodoGroup(group)
@ -82,7 +82,7 @@ func TestApi(t *testing.T) {
Text: "Do the dishes",
TimeCreatedUnix: dbGroup.TimeCreatedUnix,
DueUnix: 0,
OwnerUsername: user.Username,
OwnerEmail: user.Email,
}
err = db.CreateTodo(todo)
if err != nil {
@ -90,7 +90,7 @@ func TestApi(t *testing.T) {
}
// Now deletion
err = db.DeleteUserClean(user.Username)
err = db.DeleteUserClean(user.Email)
if err != nil {
t.Fatalf("couldn't cleanly delete user with all TODOs: %s", err)
}

186
src/db/group.go

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

168
src/db/todo.go

@ -1,6 +1,6 @@
/*
dela - web TODO list
Copyright (C) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte)
Copyright (C) 2023, 2024 Kasyanov Nikolay Alexeyevich (Unbewohnte)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
@ -18,15 +18,10 @@
package db
import "database/sql"
// Todo group structure
type TodoGroup struct {
ID uint64 `json:"id"`
Name string `json:"name"`
TimeCreatedUnix uint64 `json:"timeCreatedUnix"`
OwnerUsername string `json:"ownerUsername"`
}
import (
"database/sql"
"time"
)
// Todo structure
type Todo struct {
@ -35,98 +30,22 @@ type Todo struct {
Text string `json:"text"`
TimeCreatedUnix uint64 `json:"timeCreatedUnix"`
DueUnix uint64 `json:"dueUnix"`
OwnerUsername string `json:"ownerUsername"`
OwnerEmail string `json:"ownerEmail"`
IsDone bool `json:"isDone"`
CompletionTimeUnix uint64 `json:"completionTimeUnix"`
Image []byte `json:"image"`
TimeCreated string
CompletionTime string
Due string
}
// 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.OwnerUsername,
)
return err
}
func scanTodoGroup(rows *sql.Rows) (*TodoGroup, error) {
var newTodoGroup TodoGroup
err := rows.Scan(
&newTodoGroup.ID,
&newTodoGroup.Name,
&newTodoGroup.TimeCreatedUnix,
&newTodoGroup.OwnerUsername,
)
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)
func unixToTimeStr(unixTimeSec uint64) string {
timeUnix := time.Unix(int64(unixTimeSec), 0)
if timeUnix.Year() == 1970 {
return "None"
} else {
return timeUnix.Format(time.DateOnly)
}
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 scanTodo(rows *sql.Rows) (*Todo, error) {
@ -137,14 +56,20 @@ func scanTodo(rows *sql.Rows) (*Todo, error) {
&newTodo.Text,
&newTodo.TimeCreatedUnix,
&newTodo.DueUnix,
&newTodo.OwnerUsername,
&newTodo.OwnerEmail,
&newTodo.IsDone,
&newTodo.CompletionTimeUnix,
&newTodo.Image,
)
if err != nil {
return nil, err
}
// Convert to Basic time
newTodo.TimeCreated = unixToTimeStr(newTodo.TimeCreatedUnix)
newTodo.Due = unixToTimeStr(newTodo.DueUnix)
newTodo.CompletionTime = unixToTimeStr(newTodo.CompletionTimeUnix)
return &newTodo, nil
}
@ -192,14 +117,15 @@ func (db *DB) GetTodos() ([]*Todo, error) {
// Creates a new TODO in the database
func (db *DB) CreateTodo(todo Todo) error {
_, err := db.Exec(
"INSERT INTO todos(group_id, text, time_created_unix, due_unix, owner_username, is_done, completion_time_unix) VALUES(?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO todos(group_id, text, time_created_unix, due_unix, owner_email, is_done, completion_time_unix, image) VALUES(?, ?, ?, ?, ?, ?, ?, ?)",
todo.GroupID,
todo.Text,
todo.TimeCreatedUnix,
todo.DueUnix,
todo.OwnerUsername,
todo.OwnerEmail,
todo.IsDone,
todo.CompletionTimeUnix,
todo.Image,
)
return err
@ -215,15 +141,16 @@ func (db *DB) DeleteTodo(id uint64) error {
return err
}
// Updates TODO's due date, text, done state, completion time and group id
// Updates TODO's due date, text, done state, completion time and group id with image
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=?",
"UPDATE todos SET group_id=?, due_unix=?, text=?, is_done=?, completion_time_unix=?, image=? WHERE id=?",
updatedTodo.GroupID,
updatedTodo.DueUnix,
updatedTodo.Text,
updatedTodo.IsDone,
updatedTodo.CompletionTimeUnix,
updatedTodo.Image,
todoID,
)
@ -231,12 +158,12 @@ func (db *DB) UpdateTodo(todoID uint64, updatedTodo Todo) error {
}
// Searches and retrieves TODO groups created by the user
func (db *DB) GetAllUserTodoGroups(username string) ([]*TodoGroup, error) {
func (db *DB) GetAllUserTodoGroups(email string) ([]*TodoGroup, error) {
var todoGroups []*TodoGroup
rows, err := db.Query(
"SELECT * FROM todo_groups WHERE owner_username=?",
username,
"SELECT * FROM todo_groups WHERE owner_email=?",
email,
)
if err != nil {
return nil, err
@ -255,12 +182,12 @@ func (db *DB) GetAllUserTodoGroups(username string) ([]*TodoGroup, error) {
}
// Searches and retrieves TODOs created by the user
func (db *DB) GetAllUserTodos(username string) ([]*Todo, error) {
func (db *DB) GetAllUserTodos(email string) ([]*Todo, error) {
var todos []*Todo
rows, err := db.Query(
"SELECT * FROM todos WHERE owner_username=?",
username,
"SELECT * FROM todos WHERE owner_email=?",
email,
)
if err != nil {
return nil, err
@ -280,21 +207,34 @@ func (db *DB) GetAllUserTodos(username string) ([]*Todo, error) {
}
// Deletes all information regarding TODOs of specified user
func (db *DB) DeleteAllUserTodos(username string) error {
func (db *DB) DeleteAllUserTodos(email string) error {
_, err := db.Exec(
"DELETE FROM todos WHERE owner_username=?",
username,
"DELETE FROM todos WHERE owner_email=?",
email,
)
return err
}
// Deletes all information regarding TODO groups of specified user
func (db *DB) DeleteAllUserTodoGroups(username string) error {
func (db *DB) DeleteAllUserTodoGroups(email string) error {
_, err := db.Exec(
"DELETE FROM todo_groups WHERE owner_username=?",
username,
"DELETE FROM todo_groups WHERE owner_email=?",
email,
)
return err
}
func (db *DB) DoesUserOwnTodo(todoId uint64, email string) bool {
todo, err := db.GetTodo(todoId)
if err != nil {
return false
}
if todo.OwnerEmail != email {
return false
}
return true
}

76
src/db/user.go

@ -1,6 +1,6 @@
/*
dela - web TODO list
Copyright (C) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte)
Copyright (C) 2023, 2024 Kasyanov Nikolay Alexeyevich (Unbewohnte)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
@ -22,15 +22,16 @@ import "database/sql"
// User structure
type User struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
TimeCreatedUnix uint64 `json:"timeCreatedUnix"`
ConfirmedEmail bool `json:"confirmedEmail"`
}
func scanUser(rows *sql.Rows) (*User, error) {
rows.Next()
var user User
err := rows.Scan(&user.Username, &user.Password, &user.TimeCreatedUnix)
err := rows.Scan(&user.Email, &user.Password, &user.TimeCreatedUnix, &user.ConfirmedEmail)
if err != nil {
return nil, err
}
@ -38,9 +39,9 @@ func scanUser(rows *sql.Rows) (*User, error) {
return &user, nil
}
// Searches for user with username and returns it
func (db *DB) GetUser(username string) (*User, error) {
rows, err := db.Query("SELECT * FROM users WHERE username=?", username)
// Searches for user with email and returns it
func (db *DB) GetUser(email string) (*User, error) {
rows, err := db.Query("SELECT * FROM users WHERE email=?", email)
if err != nil {
return nil, err
}
@ -57,41 +58,84 @@ func (db *DB) GetUser(username string) (*User, error) {
// Creates a new user in the database
func (db *DB) CreateUser(newUser User) error {
_, err := db.Exec(
"INSERT INTO users(username, password, time_created_unix) VALUES(?, ?, ?)",
newUser.Username,
"INSERT INTO users(email, password, time_created_unix, confirmed_email) VALUES(?, ?, ?, ?)",
newUser.Email,
newUser.Password,
newUser.TimeCreatedUnix,
newUser.ConfirmedEmail,
)
return err
}
// Deletes user with given username
func (db *DB) DeleteUser(username string) error {
// Deletes user with given email address
func (db *DB) DeleteUser(email string) error {
_, err := db.Exec(
"DELETE FROM users WHERE username=?",
username,
"DELETE FROM users WHERE email=?",
email,
)
return err
}
// Updades user's email address, password, email confirmation with given email address
func (db *DB) UserUpdate(newUser User) error {
_, err := db.Exec(
"UPDATE users SET email=?, password=?, confirmed_email=? WHERE email=?",
newUser.Email,
newUser.Password,
newUser.ConfirmedEmail,
newUser.Email,
)
return err
}
// Deletes a user and all his TODOs (with groups) as well
func (db *DB) DeleteUserClean(username string) error {
err := db.DeleteAllUserTodoGroups(username)
func (db *DB) DeleteUserClean(email string) error {
err := db.DeleteAllUserTodoGroups(email)
if err != nil {
return err
}
err = db.DeleteAllUserTodos(username)
err = db.DeleteAllUserTodos(email)
if err != nil {
return err
}
err = db.DeleteUser(username)
err = db.DeleteUser(email)
if err != nil {
return err
}
return nil
}
// Sets confirmed_email to true for given user
func (db *DB) UserSetEmailConfirmed(email string) error {
_, err := db.Exec(
"UPDATE users SET confirmed_email=? WHERE email=?",
true,
email,
)
return err
}
// Cleanly deletes user if email is not confirmed
func (db *DB) DeleteUnverifiedUserClean(email string) error {
user, err := db.GetUser(email)
if err != nil {
return err
}
if !user.ConfirmedEmail {
// Email is not verified, delete information on this user
err = db.DeleteUserClean(email)
if err != nil {
return err
}
}
return nil
}

132
src/db/verification.go

@ -0,0 +1,132 @@
package db
import "database/sql"
type Verification struct {
ID uint64 `json:"id"`
Email string `json:"email"`
Code string `json:"code"`
IssuedUnix uint64 `json:"issued_unix"`
LifeSeconds uint64 `json:"life_seconds"`
}
func NewVerification(email string, code string, issuedUnix uint64, lifeSeconds uint64) *Verification {
return &Verification{
Email: email,
Code: code,
IssuedUnix: issuedUnix,
LifeSeconds: lifeSeconds,
}
}
func scanVerification(rows *sql.Rows) (*Verification, error) {
var newVerification Verification
err := rows.Scan(
&newVerification.ID,
&newVerification.Email,
&newVerification.Code,
&newVerification.IssuedUnix,
&newVerification.LifeSeconds,
)
if err != nil {
return nil, err
}
return &newVerification, nil
}
// Retrieves a verification with given Id from the database
func (db *DB) GetVerification(id uint64) (*Verification, error) {
rows, err := db.Query(
"SELECT * FROM verifications WHERE id=?",
id,
)
if err != nil {
return nil, err
}
defer rows.Close()
rows.Next()
verification, err := scanVerification(rows)
if err != nil {
return nil, err
}
return verification, nil
}
// Returns the first email verification by email
func (db *DB) GetVerificationByEmail(email string) (*Verification, error) {
rows, err := db.Query(
"SELECT * FROM verifications WHERE email=?",
email,
)
if err != nil {
return nil, err
}
defer rows.Close()
rows.Next()
verification, err := scanVerification(rows)
if err != nil {
return nil, err
}
return verification, nil
}
// Retrieves information on ALL TODOs
func (db *DB) GetVerifications() ([]*Verification, error) {
var verifications []*Verification
rows, err := db.Query("SELECT * FROM verifications")
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
verification, err := scanVerification(rows)
if err != nil {
return verifications, err
}
verifications = append(verifications, verification)
}
return verifications, nil
}
// Creates a new verification in the database
func (db *DB) CreateVerification(verification Verification) error {
_, err := db.Exec(
"INSERT INTO verifications(email, code, issued_unix, life_seconds) VALUES(?, ?, ?, ?)",
verification.Email,
verification.Code,
verification.IssuedUnix,
verification.LifeSeconds,
)
return err
}
// Deletes information about a verification of certain ID from the database
func (db *DB) DeleteVerification(id uint64) error {
_, err := db.Exec(
"DELETE FROM verifications WHERE id=?",
id,
)
return err
}
// Updates verification
func (db *DB) UpdateVerification(verificationID uint64, updatedTodo Verification) error {
_, err := db.Exec(
"UPDATE verifications SET code=?, life_seconds=? WHERE id=?",
updatedTodo.Code,
updatedTodo.LifeSeconds,
verificationID,
)
return err
}

15
src/email/auth.go

@ -0,0 +1,15 @@
package email
import (
"Unbewohnte/dela/conf"
"net/smtp"
)
func Auth(conf conf.Conf) smtp.Auth {
return smtp.PlainAuth(
"",
conf.Verification.Emailer.User,
conf.Verification.Emailer.Password,
conf.Verification.Emailer.Host,
)
}

52
src/email/email.go

@ -0,0 +1,52 @@
package email
import (
"fmt"
"net/smtp"
"strings"
)
type Email struct {
Sender string
To []string
Subject string
Body string
}
func NewEmail(sender, subject, body string, to []string) Email {
return Email{
Sender: sender,
To: to,
Subject: subject,
Body: body,
}
}
type Emailer struct {
Auth smtp.Auth
Address string
From string
}
func NewEmailer(auth smtp.Auth, addr string, from string) *Emailer {
return &Emailer{
Auth: auth,
Address: addr,
From: from,
}
}
func buildEmail(mail Email) []byte {
message := "MIME-version: 1.0;\nContent-Type: text/html; charset=\"UTF-8\";\r\n"
message += fmt.Sprintf("From: %s\r\n", mail.Sender)
message += fmt.Sprintf("To: %s\r\n", strings.Join(mail.To, ";"))
message += fmt.Sprintf("Subject: %s\r\n", mail.Subject)
message += fmt.Sprintf("\r\n%s\r\n", mail.Body)
return []byte(message)
}
func (em *Emailer) SendEmail(mail Email) error {
err := smtp.SendMail(em.Address, em.Auth, em.From, mail.To, buildEmail(mail))
return err
}

43
src/encryption/encryption.go

@ -1,43 +0,0 @@
/*
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))
}

4
src/main.go

@ -1,6 +1,6 @@
/*
dela - web TODO list
Copyright (C) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte)
Copyright (C) 2024 Kasyanov Nikolay Alexeyevich (Unbewohnte)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
@ -28,7 +28,7 @@ import (
"path/filepath"
)
const Version string = "0.1.2"
const Version string = "0.2.0"
var (
printVersion *bool = flag.Bool("version", false, "Print version information and exit")

16
src/misc/codeNumeric.go

@ -0,0 +1,16 @@
package misc
import (
"math/rand"
"strconv"
)
// Generates a pseudo-random numeric code of required length
func GenerateNumericCode(length uint) string {
code := ""
for i := 0; uint(i) < length; i++ {
code += strconv.Itoa(rand.Intn(10))
}
return code
}

455
src/server/api.go

@ -1,455 +0,0 @@
/*
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) UserEndpoint(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodDelete:
// Delete an existing user
defer req.Body.Close()
username := GetUsernameFromAuth(req)
// Check if auth data is valid
if !IsRequestAuthValid(req, s.db) {
logger.Warning("[Server] %s failed to authenticate as %s", req.RemoteAddr, username)
http.Error(w, "Invalid user auth data", http.StatusBadRequest)
return
}
// It is, indeed, a user
// Delete with all TODOs
err := s.db.DeleteUserClean(username)
if err != nil {
logger.Error("[Server] Failed to delete %s: %s", username, err)
http.Error(w, "Failed to delete user or TODO contents", http.StatusInternalServerError)
return
}
// Success!
w.WriteHeader(http.StatusOK)
case http.MethodPost:
// Create a new user
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 user: %s", err)
http.Error(w, "Failed to read body", http.StatusInternalServerError)
return
}
// Unmarshal JSON
var newUser db.User
err = json.Unmarshal(body, &newUser)
if err != nil {
logger.Warning("[Server] Received invalid user JSON for creation: %s", err)
http.Error(w, "Invalid user JSON", http.StatusBadRequest)
return
}
// Check for validity
valid, reason := IsUserValid(newUser)
if !valid {
logger.Info("[Server] Rejected creating %s for reason: %s", newUser.Username, reason)
http.Error(w, "Invalid user data: "+reason, http.StatusBadRequest)
return
}
// Add user to the database
newUser.TimeCreatedUnix = uint64(time.Now().Unix())
err = s.db.CreateUser(newUser)
if err != nil {
http.Error(w, "User already exists", http.StatusInternalServerError)
return
}
// Create an initial TODO group
err = s.db.CreateTodoGroup(
db.TodoGroup{
Name: "Todos",
TimeCreatedUnix: uint64(time.Now().Unix()),
OwnerUsername: newUser.Username,
},
)
if err != nil {
// Oops, that's VERY bad. Delete newly created user
s.db.DeleteUser(newUser.Username)
logger.Error("[SERVER] Failed to create an initial TODO group for a newly created \"%s\": %s. Deleted.", newUser.Username, err)
http.Error(w, "Failed to create initial TODO group", http.StatusInternalServerError)
return
}
// Success!
w.WriteHeader(http.StatusOK)
logger.Info("[Server] Created a new user \"%s\"", newUser.Username)
case http.MethodGet:
// Check if user information is valid
if !IsRequestAuthValid(req, s.db) {
http.Error(w, "Invalid user auth data", http.StatusForbidden)
return
}
w.WriteHeader(http.StatusOK)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) SpecificTodoEndpoint(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodDelete:
// Delete an existing TODO
defer req.Body.Close()
// Check if this user actually exists and the password is valid
if !IsRequestAuthValid(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 !DoesUserOwnTodo(GetUsernameFromAuth(req), todoID, s.db) {
http.Error(w, "You don't own this TODO", http.StatusForbidden)
return
}
// // Mark TODO as done and assign a completion time
// updatedTodo, err := s.db.GetTodo(todoID)
// if err != nil {
// logger.Error("[Server] Failed to get todo with id %d for marking completion: %s", todoID, err)
// http.Error(w, "TODO retrieval error", http.StatusInternalServerError)
// return
// }
// updatedTodo.IsDone = true
// updatedTodo.CompletionTimeUnix = uint64(time.Now().Unix())
// err = s.db.UpdateTodo(todoID, *updatedTodo)
// if err != nil {
// logger.Error("[Server] Failed to update TODO with id %d: %s", todoID, err)
// http.Error(w, "Failed to update TODO information", http.StatusInternalServerError)
// return
// }
// Now delete
err = s.db.DeleteTodo(todoID)
if err != nil {
logger.Error("[Server] Failed to delete %s's TODO: %s", GetUsernameFromAuth(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)
case http.MethodPost:
// Change TODO information
// Check authentication information
if !IsRequestAuthValid(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 !DoesUserOwnTodo(GetUsernameFromAuth(req), todoID, s.db) {
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)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) TodoEndpoint(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodPost:
// 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 !IsRequestAuthValid(req, s.db) {
http.Error(w, "Invalid user auth data", http.StatusForbidden)
return
}
// Add TODO to the database
newTodo.OwnerUsername = GetUsernameFromAuth(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.OwnerUsername)
case http.MethodGet:
// Retrieve TODO information
// Check authentication information
if !IsRequestAuthValid(req, s.db) {
http.Error(w, "Invalid user auth data", http.StatusForbidden)
return
}
// Get all user TODOs
todos, err := s.db.GetAllUserTodos(GetUsernameFromAuth(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)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) TodoGroupEndpoint(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodDelete:
// 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 !IsRequestAuthValid(req, s.db) {
http.Error(w, "Invalid user auth data", http.StatusForbidden)
return
}
if !DoesUserOwnTodoGroup(GetUsernameFromAuth(req), group.ID, s.db) {
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", GetUsernameFromAuth(req), err)
http.Error(w, "Failed to delete TODO group", http.StatusInternalServerError)
return
}
// Success!
w.WriteHeader(http.StatusOK)
case http.MethodPost:
// 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 !IsRequestAuthValid(req, s.db) {
http.Error(w, "Invalid user auth data", http.StatusForbidden)
return
}
// Add group to the database
newGroup.OwnerUsername = GetUsernameFromAuth(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.OwnerUsername)
case http.MethodGet:
// Retrieve all todo groups
// Check authentication information
if !IsRequestAuthValid(req, s.db) {
http.Error(w, "Invalid user auth data", http.StatusForbidden)
return
}
// Get groups
groups, err := s.db.GetAllUserTodoGroups(GetUsernameFromAuth(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)
case http.MethodPatch:
// Check authentication information
if !IsRequestAuthValid(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)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}

15
src/server/api_test.go

@ -54,7 +54,7 @@ func TestApi(t *testing.T) {
// Create a new user
newUser := db.User{
Username: "user1",
Email: "user1",
Password: "ruohguoeruoger",
TimeCreatedUnix: 12421467,
}
@ -63,7 +63,7 @@ func TestApi(t *testing.T) {
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))
resp, err := http.Post(fmt.Sprintf("http://localhost:%d/api/user", config.Server.Port), "application/json", bytes.NewBuffer(newUserJsonBytes))
if err != nil {
t.Fatalf("failed to post a new user data: %s", err)
}
@ -81,7 +81,7 @@ func TestApi(t *testing.T) {
// newGroup := db.TodoGroup{
// Name: "group1",
// TimeCreatedUnix: 13524534,
// OwnerUsername: newUser.Username,
// OwnerUsername: newUser.Email,
// }
// newGroupBytes, err := json.Marshal(&newGroup)
// if err != nil {
@ -92,7 +92,7 @@ func TestApi(t *testing.T) {
// 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.Username, RequestHeaderAuthSeparator, newUser.Password))
// req.Header.Add(RequestHeaderAuthKey, fmt.Sprintf("%s%s%s", newUser.Email, RequestHeaderAuthSeparator, newUser.Password))
// req.Header.Add(RequestHeaderEncodedB64, "false")
// resp, err = http.DefaultClient.Do(req)
@ -112,12 +112,11 @@ func TestApi(t *testing.T) {
// TODO creation
var newTodo db.Todo = db.Todo{
// GroupID: newGroup.ID,
GroupID: 0,
Text: "Do the dishes",
TimeCreatedUnix: uint64(time.Now().UnixMicro()),
DueUnix: uint64(time.Now().Add(time.Hour * 5).UnixMicro()),
OwnerUsername: newUser.Username,
OwnerEmail: newUser.Email,
}
newTodoBytes, err := json.Marshal(&newTodo)
@ -125,12 +124,10 @@ func TestApi(t *testing.T) {
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))
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/todo", config.Server.Port), bytes.NewBuffer(newTodoBytes))
if err != nil {
t.Fatalf("failed to create a new POST request to create a new TODO: %s", err)
}
req.Header.Add(RequestHeaderAuthKey, fmt.Sprintf("%s%s%s", newUser.Username, RequestHeaderAuthSeparator, newUser.Password))
req.Header.Add(RequestHeaderEncodedB64, "false")
resp, err = http.DefaultClient.Do(req)
if err != nil {

140
src/server/auth.go

@ -1,140 +0,0 @@
/*
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/encryption"
"net/http"
"strconv"
"strings"
)
const (
RequestHeaderSecurityKey string = "Security-Key"
// RequestHeaderAuthSeparator string = "\u200b" // username\u200bpassword
RequestHeaderAuthSeparator string = "<-->" // username<-->password
RequestHeaderAuthKey string = "Auth"
RequestHeaderTodoIDKey string = "Todo-Key"
RequestHeaderEncodedB64 string = "EncryptedBase64" // tells whether auth data is encoded in base64
)
// Checks if the request header contains a valid full access key string or not
func DoesRequestHasFullAccess(req *http.Request, accessKey string) bool {
var headerAccessKey string
if req.Header.Get(RequestHeaderEncodedB64) == "true" {
headerAccessKey = encryption.DecodeString(req.Header.Get(RequestHeaderSecurityKey))
} else {
headerAccessKey = req.Header.Get(RequestHeaderSecurityKey)
}
if headerAccessKey == "" || headerAccessKey != accessKey {
return false
}
return true
}
// Gets auth data from the request and rips the login string from it. Returns ""
// if there is no auth data at all
func GetUsernameFromAuth(req *http.Request) string {
var authInfoStr string
if req.Header.Get(RequestHeaderEncodedB64) == "true" {
authInfoStr = encryption.DecodeString(req.Header.Get(RequestHeaderAuthKey))
} else {
authInfoStr = req.Header.Get(RequestHeaderAuthKey)
}
authInfoSplit := strings.Split(authInfoStr, RequestHeaderAuthSeparator)
if len(authInfoSplit) != 2 {
// no separator or funny username|password
return ""
}
username := authInfoSplit[0]
return username
}
// Verifies if the request contains a valid user auth information (username-password pair)
func IsRequestAuthValid(req *http.Request, db *db.DB) bool {
var authInfoStr string
if req.Header.Get(RequestHeaderEncodedB64) == "true" {
authInfoStr = encryption.DecodeString(req.Header.Get(RequestHeaderAuthKey))
} else {
authInfoStr = req.Header.Get(RequestHeaderAuthKey)
}
authInfoSplit := strings.Split(authInfoStr, RequestHeaderAuthSeparator)
if len(authInfoSplit) != 2 {
// no separator or funny id|password
return false
}
username, password := authInfoSplit[0], authInfoSplit[1]
user, err := db.GetUser(username)
if err != nil {
// does not exist
return false
}
if password != user.Password {
// password does not match
return false
}
return true
}
// Checks if given user owns a todo
func DoesUserOwnTodo(username string, todoID uint64, db *db.DB) bool {
todo, err := db.GetTodo(todoID)
if err != nil {
return false
}
if todo.OwnerUsername != username {
return false
}
return true
}
// Checks if given user owns a todo group
func DoesUserOwnTodoGroup(username string, todoGroupID uint64, db *db.DB) bool {
group, err := db.GetTodoGroup(todoGroupID)
if err != nil {
return false
}
if group.OwnerUsername != username {
return false
}
return true
}
// Retrieves todo ID from request headers
func GetTodoIDFromReq(req *http.Request) (uint64, error) {
todoIDStr := encryption.DecodeString(req.Header.Get(RequestHeaderTodoIDKey))
todoID, err := strconv.ParseUint(todoIDStr, 10, 64)
if err != nil {
return 0, err
}
return todoID, nil
}

772
src/server/endpoints.go

@ -0,0 +1,772 @@
/*
dela - web TODO list
Copyright (C) 2023, 2024 Kasyanov Nikolay Alexeyevich (Unbewohnte)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package server
import (
"Unbewohnte/dela/db"
"Unbewohnte/dela/email"
"Unbewohnte/dela/logger"
"encoding/json"
"fmt"
"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.Email, err)
http.Error(w, "Failed to create user", http.StatusInternalServerError)
return
}
logger.Info("[Server][EndpointUserCreate] Created a new user with email \"%s\"", user.Email)
// Create a non-removable default category
err = s.db.CreateTodoGroup(db.NewTodoGroup(
"Notes",
uint64(time.Now().Unix()),
user.Email,
false,
))
if err != nil {
http.Error(w, "Failed to create default group", http.StatusInternalServerError)
logger.Error("[Server][EndpointUserCreate] Failed to create a default group for %s: %s", user.Email, err)
return
}
// Check if email verification is required
if !s.config.Verification.VerifyEmails {
// Do not verify email
// Send cookie
http.SetCookie(w, &http.Cookie{
Name: "auth",
Value: fmt.Sprintf("%s:%s", user.Email, user.Password),
SameSite: http.SameSiteStrictMode,
HttpOnly: false,
Path: "/",
Secure: false,
})
// Done
w.Write([]byte("{\"confirm_email\":false}"))
logger.Info("[Server][EndpointUserCreate] Successfully sent email notification to %s", user.Email)
return
}
// Send email verification message
verification, err := GenerateVerificationCode(s.db, user.Email)
if err != nil {
logger.Error("[Server][EndpointUserCreate] Failed to generate verification code for %s: %s", user.Email, err)
http.Error(w, "Failed to generate confirmation code", http.StatusInternalServerError)
return
}
// Send verification email
err = s.emailer.SendEmail(
email.NewEmail(
s.config.Verification.Emailer.User,
"Dela: Email verification",
fmt.Sprintf("Your email verification code: <b>%s</b>\nPlease, verify your email in %f hours.\nThis email was specified during Dela account creation. Ignore this message if it wasn't you", verification.Code, float32(verification.LifeSeconds)/3600),
[]string{user.Email},
),
)
if err != nil {
logger.Error("[Server][EndpointUserCreate] Failed to send verification email to %s: %s", user.Email, err)
http.Error(w, "Failed to send email verification message", http.StatusInternalServerError)
return
}
// Autodelete user account if email was not verified in time
time.AfterFunc(time.Second*time.Duration(verification.LifeSeconds), func() {
err = s.db.DeleteUnverifiedUserClean(user.Email)
if err != nil {
logger.Error("[Server][EndpointUserCreate] Failed to autodelete unverified user %s: %s", user.Email, err)
}
})
w.Write([]byte("{\"confirm_email\":true}"))
}
func (s *Server) EndpointUserVerify(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Retrieve data
defer req.Body.Close()
contents, err := io.ReadAll(req.Body)
if err != nil {
logger.Error("[Server][EndpointUserVerify] Failed to read request body: %s", err)
http.Error(w, "Failed to read request body", http.StatusInternalServerError)
return
}
type verificationAnswer struct {
Email string `json:"email"`
Code string `json:"code"`
}
var answer verificationAnswer
err = json.Unmarshal(contents, &answer)
if err != nil {
logger.Error("[Server][EndpointUserVerify] Failed to unmarshal verification answer: %s", err)
http.Error(w, "Verification answer JSON unmarshal error", http.StatusInternalServerError)
return
}
// Retrieve user
user, err := s.db.GetUser(answer.Email)
if err != nil {
logger.Error("[Server][EndpointUserVerify] Failed to retrieve information on \"%s\": %s", answer.Email, err)
http.Error(w, "Failed to get user information", http.StatusInternalServerError)
return
}
// Compare codes
dbCode, err := s.db.GetVerificationByEmail(user.Email)
if err != nil {
logger.Error("[Server][EndpointUserVerify] Could not get verification code from DB for %s: %s", user.Email, err)
http.Error(w, "Could not retrieve verification information for this email", http.StatusInternalServerError)
return
}
if answer.Code != dbCode.Code {
// Codes do not match!
logger.Error("[Server][EndpointUserVerify] %s sent wrong verification code", user.Email)
http.Error(w, "Wrong verification code!", http.StatusForbidden)
return
}
// All's good!
err = s.db.UserSetEmailConfirmed(user.Email)
if err != nil {
http.Error(w, "Failed to save confirmation information", http.StatusInternalServerError)
logger.Error("[Server][EndpointUserVerify] Failed to set confirmed_email to true for %s: %s", user.Email, err)
return
}
logger.Info("[Server][EndpointUserVerify] %s was successfully verified!", user.Email)
// Send cookie
http.SetCookie(w, &http.Cookie{
Name: "auth",
Value: fmt.Sprintf("%s:%s", user.Email, user.Password),
SameSite: http.SameSiteStrictMode,
HttpOnly: false,
Path: "/",
Secure: false,
})
w.WriteHeader(http.StatusOK)
}
func (s *Server) EndpointUserLogin(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][EndpointUserLogin] 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][EndpointUserLogin] Failed to unmarshal user data: %s", err)
http.Error(w, "User JSON unmarshal error", http.StatusInternalServerError)
return
}
// Check auth data
if !IsUserAuthorized(s.db, user) {
http.Error(w, "Failed auth", http.StatusForbidden)
return
}
// Send cookie
http.SetCookie(w, &http.Cookie{
Name: "auth",
Value: fmt.Sprintf("%s:%s", user.Email, user.Password),
SameSite: http.SameSiteStrictMode,
HttpOnly: false,
Path: "/",
Secure: false,
})
w.WriteHeader(http.StatusOK)
}
func (s *Server) EndpointUserUpdate(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Retrieve user data
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
email := GetLoginFromReq(req)
if email != user.Email {
// Gotcha!
logger.Warning("[Server][EndpointUserUpdate] %s tried to update user information of %s!", email, user.Email)
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.Email, err)
return
}
logger.Info("[Server][EndpointUserUpdate] Updated a user with email \"%s\"", user.Email)
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
email := GetLoginFromReq(req)
err := s.db.DeleteUser(email)
if err != nil {
http.Error(w, "Failed to delete user", http.StatusInternalServerError)
logger.Error("[Server][EndpointUserDelete] Failed to delete \"%s\": %s", email, err)
return
}
logger.Info("[Server][EndpointUserDelete] Deleted a user with email \"%s\"", email)
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
email := GetLoginFromReq(req)
userDB, err := s.db.GetUser(email)
if err != nil {
logger.Error("[Server][EndpointUserGet] Failed to retrieve information on \"%s\": %s", email, 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", email, 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, GetLoginFromReq(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) EndpointTodoMarkDone(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, GetLoginFromReq(req)) {
http.Error(w, "You don't own this TODO", http.StatusForbidden)
return
}
todo, err := s.db.GetTodo(todoID)
if err != nil {
http.Error(w, "Can't access this TODO", http.StatusInternalServerError)
return
}
// Update
todo.IsDone = true
todo.CompletionTimeUnix = uint64(time.Now().Unix())
err = s.db.UpdateTodo(todoID, *todo)
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] Marked TODO as done %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, GetLoginFromReq(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", GetLoginFromReq(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()
if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 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
if newTodo.GroupID == 0 {
http.Error(w, "No group ID was provided", http.StatusBadRequest)
return
}
if !s.db.DoesUserOwnGroup(newTodo.GroupID, GetLoginFromReq(req)) {
http.Error(w, "You do not own this group", http.StatusForbidden)
return
}
newTodo.OwnerEmail = GetLoginFromReq(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.OwnerEmail)
}
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(GetLoginFromReq(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()
if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Check if given user actually owns this group
if !IsUserAuthorizedReq(req, s.db) {
http.Error(w, "Invalid user auth data", http.StatusForbidden)
return
}
// Get group ID
groupId, err := strconv.ParseUint(path.Base(req.URL.Path), 10, 64)
if err != nil {
http.Error(w, "Bad Category ID", http.StatusBadRequest)
return
}
if !s.db.DoesUserOwnGroup(groupId, GetLoginFromReq(req)) {
http.Error(w, "You don't own this group", http.StatusForbidden)
return
}
groupDB, err := s.db.GetTodoGroup(groupId)
if err != nil {
logger.Error("[Server][EndpointGroupDelete] Failed to fetch TODO group with Id %d: %s", groupId, err)
http.Error(w, "Failed to retrieve TODO group", http.StatusInternalServerError)
return
}
if !groupDB.Removable {
// Not removable
http.Error(w, "Not removable", http.StatusBadRequest)
return
}
// Delete all ToDos associated with this group and then delete the group itself
err = s.db.DeleteTodoGroupClean(groupId)
if err != nil {
logger.Error("[Server][EndpointGroupDelete] Failed to delete %s's TODO group: %s", GetLoginFromReq(req), err)
http.Error(w, "Failed to delete TODO group", http.StatusInternalServerError)
return
}
// Success!
logger.Info("[Server][EndpointGroupDelete] Cleanly deleted group ID: %d for %s", groupId, GetLoginFromReq(req))
w.WriteHeader(http.StatusOK)
}
func (s *Server) EndpointTodoGroupCreate(w http.ResponseWriter, req *http.Request) {
// Create a new TODO group
defer req.Body.Close()
if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 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.OwnerEmail = GetLoginFromReq(req)
newGroup.TimeCreatedUnix = uint64(time.Now().Unix())
newGroup.Removable = true
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.OwnerEmail)
}
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(GetLoginFromReq(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)
}

45
src/server/page.go

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

155
src/server/server.go

@ -1,6 +1,6 @@
/*
dela - web TODO list
Copyright (C) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte)
Copyright (C) 2023, 2024 Kasyanov Nikolay Alexeyevich (Unbewohnte)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
@ -21,12 +21,17 @@ package server
import (
"Unbewohnte/dela/conf"
"Unbewohnte/dela/db"
"Unbewohnte/dela/email"
"Unbewohnte/dela/logger"
"context"
"fmt"
"net/http"
"net/http/cookiejar"
"os"
"path"
"path/filepath"
"strconv"
"text/template"
"time"
)
@ -40,6 +45,8 @@ type Server struct {
config conf.Conf
db *db.DB
http http.Server
cookieJar *cookiejar.Jar
emailer *email.Emailer
}
// Creates a new server instance with provided config
@ -81,7 +88,7 @@ func New(config conf.Conf) (*Server, error) {
// start constructing an http server configuration
server.http = http.Server{
Addr: fmt.Sprintf(":%d", server.config.Port),
Addr: fmt.Sprintf(":%d", server.config.Server.Port),
}
// configure paths' callbacks
@ -101,43 +108,139 @@ func New(config conf.Conf) (*Server, error) {
)
// handle page requests
pagesDirPath := filepath.Join(server.config.BaseContentDir, PagesDirName)
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
// Ignore favicon request. It's not an .ico
if req.URL.Path == "favicon.ico" {
if req.Method != "GET" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
switch req.URL.Path {
case "/":
requestedPage, err := getPage(
filepath.Join(server.config.BaseContentDir, PagesDirName), "base.html", "index.html",
if req.URL.Path == "/" {
// Auth first
if !IsUserAuthorizedReq(req, server.db) {
http.Redirect(w, req, "/about", http.StatusTemporaryRedirect)
return
}
requestedPage, err := template.ParseFiles(
filepath.Join(pagesDirPath, "base.html"),
filepath.Join(pagesDirPath, "index.html"),
)
if err != nil {
http.Redirect(w, req, "/error", http.StatusTemporaryRedirect)
logger.Error("[Server][/] Failed to get a page: %s", err)
return
}
pageData, err := GetIndexPageData(server.db, GetLoginFromReq(req))
if err != nil {
http.Redirect(w, req, "/error", http.StatusTemporaryRedirect)
logger.Error("[Server][/] Failed to get index page data: %s", err)
return
}
err = requestedPage.ExecuteTemplate(w, "index.html", &pageData)
if err != nil {
http.Redirect(w, req, "/error", http.StatusTemporaryRedirect)
logger.Error("[Server][/category/] Template error: %s", err)
return
}
} else if path.Dir(req.URL.Path) == "/group" {
if req.Method != "GET" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Auth first
if !IsUserAuthorizedReq(req, server.db) {
http.Redirect(w, req, "/about", http.StatusTemporaryRedirect)
return
}
// Get group ID
groupId, err := strconv.ParseUint(path.Base(req.URL.Path), 10, 64)
if err != nil {
http.Redirect(w, req, "/error", http.StatusTemporaryRedirect)
return
}
// Check if it exists
if _, err = server.db.GetTodoGroup(groupId); err != nil {
// Group does not exist
http.Redirect(w, req, "/error", http.StatusTemporaryRedirect)
return
}
requestedPage, err := template.ParseFiles(
filepath.Join(pagesDirPath, "base.html"),
filepath.Join(pagesDirPath, "paint.html"),
filepath.Join(pagesDirPath, "category.html"),
)
if err != nil {
http.Error(w, "Page processing error", http.StatusInternalServerError)
http.Redirect(w, req, "/error", http.StatusTemporaryRedirect)
logger.Error("[Server][/category/] Failed to get a page: %s", err)
return
}
// Get page data
pageData, err := GetCategoryPageData(server.db, GetLoginFromReq(req), groupId)
if err != nil {
http.Redirect(w, req, "/error", http.StatusTemporaryRedirect)
logger.Error("[Server][/category/] Failed to get category (%d) page data: %s", groupId, err)
return
}
requestedPage.ExecuteTemplate(w, "index.html", nil)
err = requestedPage.ExecuteTemplate(w, "category.html", &pageData)
if err != nil {
http.Redirect(w, req, "/error", http.StatusTemporaryRedirect)
logger.Error("[Server][/category/] Template error: %s", err)
return
}
default:
requestedPage, err := getPage(
filepath.Join(server.config.BaseContentDir, PagesDirName),
"base.html",
req.URL.Path[1:]+".html",
} else {
// default
requestedPage, err := template.ParseFiles(
filepath.Join(pagesDirPath, "base.html"),
filepath.Join(pagesDirPath, req.URL.Path[1:]+".html"),
)
if err == nil {
requestedPage.ExecuteTemplate(w, req.URL.Path[1:]+".html", nil)
err = requestedPage.ExecuteTemplate(w, req.URL.Path[1:]+".html", nil)
if err != nil {
http.Redirect(w, req, "/error", http.StatusTemporaryRedirect)
logger.Error("[Server][/default] Template error: %s", err)
return
}
} else {
http.Error(w, "Page processing error", http.StatusInternalServerError)
http.Redirect(w, req, "/error", http.StatusTemporaryRedirect)
}
}
})
mux.HandleFunc("/api/user", server.UserEndpoint)
mux.HandleFunc("/api/todo", server.TodoEndpoint)
mux.HandleFunc("/api/todo/", server.SpecificTodoEndpoint)
// mux.HandleFunc("/api/group", server.TodoGroupEndpoint)
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/user/login", server.EndpointUserLogin) // Non specific
mux.HandleFunc("/api/user/verify", server.EndpointUserVerify) // Non specific
mux.HandleFunc("/api/todo/create", server.EndpointTodoCreate) // Non specific
mux.HandleFunc("/api/todo/get", server.EndpointUserTodosGet) // Non specific
mux.HandleFunc("/api/todo/delete/", server.EndpointTodoDelete) // Specific
mux.HandleFunc("/api/todo/update/", server.EndpointTodoUpdate) // Specific
mux.HandleFunc("/api/todo/markdone/", server.EndpointTodoMarkDone) // 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
jar, _ := cookiejar.New(nil)
server.cookieJar = jar
server.emailer = email.NewEmailer(
email.Auth(server.config),
fmt.Sprintf("%s:%d", server.config.Verification.Emailer.Host, server.config.Verification.Emailer.HostPort),
server.config.Verification.Emailer.User,
)
logger.Info("[Server] Created an HTTP server instance")
return &server, nil
@ -145,18 +248,18 @@ func New(config conf.Conf) (*Server, error) {
// Launches server instance
func (s *Server) Start() error {
if s.config.CertFilePath != "" && s.config.KeyFilePath != "" {
if s.config.Server.CertFilePath != "" && s.config.Server.KeyFilePath != "" {
logger.Info("[Server] Using TLS")
logger.Info("[Server] HTTP server is going live on port %d!", s.config.Port)
logger.Info("[Server] HTTP server is going live on port %d!", s.config.Server.Port)
err := s.http.ListenAndServeTLS(s.config.CertFilePath, s.config.KeyFilePath)
err := s.http.ListenAndServeTLS(s.config.Server.CertFilePath, s.config.Server.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)
logger.Info("[Server] HTTP server is going live on port %d!", s.config.Server.Port)
err := s.http.ListenAndServe()
if err != nil && err != http.ErrServerClosed {

117
src/server/validation.go

@ -1,6 +1,6 @@
/*
dela - web TODO list
Copyright (C) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte)
Copyright (C) 2023, 2024 Kasyanov Nikolay Alexeyevich (Unbewohnte)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
@ -20,32 +20,125 @@ package server
import (
"Unbewohnte/dela/db"
"Unbewohnte/dela/misc"
"fmt"
"net/http"
"strings"
"time"
)
const (
MinimalUsernameLength uint = 3
ForbiddenUsernameCharacters string = "|<>\"'`\\/\u200b"
MinimalEmailLength uint = 3
MinimalPasswordLength uint = 5
MaxEmailLength uint = 60
MaxPasswordLength uint = 250
MaxTodoLength uint = 150
)
// Check if user is valid. Returns false and a reason-string if not
func IsUserValid(user db.User) (bool, string) {
if uint(len(user.Username)) < MinimalUsernameLength {
return false, "Username is too small"
}
for _, char := range user.Username {
for _, forbiddenChar := range ForbiddenUsernameCharacters {
if char == forbiddenChar {
return false, fmt.Sprintf("Username contains a forbidden character \"%c\"", char)
}
if uint(len(user.Email)) < MinimalEmailLength {
return false, "Email is too small"
}
if uint(len(user.Email)) > MaxEmailLength {
return false, fmt.Sprintf("Email is too big; Email should be up to %d characters", MaxEmailLength)
}
if uint(len(user.Password)) < MinimalPasswordLength {
return false, "Password is too small"
}
if uint(len(user.Password)) > MaxPasswordLength {
return false, fmt.Sprintf("Password is too big; Password should be up to %d characters", MaxPasswordLength)
}
return true, ""
}
// Checks if such user exists, passwords match and email is confirmed. Returns true if such user exists, passwords do match and email was verified
func IsUserAuthorized(db *db.DB, user db.User) bool {
userDB, err := db.GetUser(user.Email)
if err != nil {
return false
}
if !userDB.ConfirmedEmail {
return false
}
if userDB.Password != user.Password {
return false
}
return true
}
// Returns email and password from a cookie. If an error is encountered, returns empty strings
func AuthFromCookie(cookie *http.Cookie) (string, string) {
if cookie == nil {
return "", ""
}
parts := strings.Split(cookie.Value, ":")
if len(parts) != 2 {
return "", ""
}
return parts[0], parts[1]
}
/*
Gets auth information from a request and
checks if such user exists and passwords match.
Returns true if such user exists, passwords do match and email is confirmed
*/
func IsUserAuthorizedReq(req *http.Request, dbase *db.DB) bool {
var email, password string
var ok bool
email, password, ok = req.BasicAuth()
if !ok || email == "" || password == "" {
cookie, err := req.Cookie("auth")
if err != nil {
return false
}
email, password = AuthFromCookie(cookie)
}
return IsUserAuthorized(dbase, db.User{
Email: email,
Password: password,
})
}
// Returns email value from basic auth or from cookie if the former does not exist
func GetLoginFromReq(req *http.Request) string {
email, _, ok := req.BasicAuth()
if !ok || email == "" {
cookie, err := req.Cookie("auth")
if err != nil {
return ""
}
email, _ = AuthFromCookie(cookie)
}
return email
}
/*
Generates a new verification code for given email with 8-digit numeric code,
current issue time and one hour lifetime.
Inserts newly created email verification into database.
*/
func GenerateVerificationCode(dbase *db.DB, email string) (*db.Verification, error) {
verification := db.NewVerification(
email, misc.GenerateNumericCode(8), uint64(time.Now().Unix()), uint64(time.Hour.Seconds()),
)
err := dbase.CreateVerification(*verification)
if err != nil {
return nil, err
}
return verification, nil
}

202
static/fonts/LICENSE.txt

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

BIN
static/fonts/Roboto-Black.ttf

Binary file not shown.

BIN
static/fonts/Roboto-BlackItalic.ttf

Binary file not shown.

BIN
static/fonts/Roboto-Bold.ttf

Binary file not shown.

BIN
static/fonts/Roboto-BoldItalic.ttf

Binary file not shown.

BIN
static/fonts/Roboto-Italic.ttf

Binary file not shown.

BIN
static/fonts/Roboto-Light.ttf

Binary file not shown.

BIN
static/fonts/Roboto-LightItalic.ttf

Binary file not shown.

BIN
static/fonts/Roboto-Medium.ttf

Binary file not shown.

BIN
static/fonts/Roboto-MediumItalic.ttf

Binary file not shown.

BIN
static/fonts/Roboto-Regular.ttf

Binary file not shown.

BIN
static/fonts/Roboto-Thin.ttf

Binary file not shown.

BIN
static/fonts/Roboto-ThinItalic.ttf

Binary file not shown.

3
static/images/arrows-fullscreen.svg

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrows-fullscreen" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M5.828 10.172a.5.5 0 0 0-.707 0l-4.096 4.096V11.5a.5.5 0 0 0-1 0v3.975a.5.5 0 0 0 .5.5H4.5a.5.5 0 0 0 0-1H1.732l4.096-4.096a.5.5 0 0 0 0-.707m4.344 0a.5.5 0 0 1 .707 0l4.096 4.096V11.5a.5.5 0 1 1 1 0v3.975a.5.5 0 0 1-.5.5H11.5a.5.5 0 0 1 0-1h2.768l-4.096-4.096a.5.5 0 0 1 0-.707m0-4.344a.5.5 0 0 0 .707 0l4.096-4.096V4.5a.5.5 0 1 0 1 0V.525a.5.5 0 0 0-.5-.5H11.5a.5.5 0 0 0 0 1h2.768l-4.096 4.096a.5.5 0 0 0 0 .707m-4.344 0a.5.5 0 0 1-.707 0L1.025 1.732V4.5a.5.5 0 0 1-1 0V.525a.5.5 0 0 1 .5-.5H4.5a.5.5 0 0 1 0 1H1.732l4.096 4.096a.5.5 0 0 1 0 .707"/>
</svg>

After

Width:  |  Height:  |  Size: 726 B

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

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

After

Width:  |  Height:  |  Size: 528 B

3
static/images/brightness-high.svg

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-brightness-high" viewBox="0 0 16 16">
<path d="M8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6m0 1a4 4 0 1 0 0-8 4 4 0 0 0 0 8M8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0m0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13m8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5M3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8m10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0m-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0m9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707M4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708"/>
</svg>

After

Width:  |  Height:  |  Size: 818 B

BIN
static/images/dela_main.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

4
static/images/emoji-frown.svg

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-emoji-frown" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
<path d="M4.285 12.433a.5.5 0 0 0 .683-.183A3.5 3.5 0 0 1 8 10.5c1.295 0 2.426.703 3.032 1.75a.5.5 0 0 0 .866-.5A4.5 4.5 0 0 0 8 9.5a4.5 4.5 0 0 0-3.898 2.25.5.5 0 0 0 .183.683M7 6.5C7 7.328 6.552 8 6 8s-1-.672-1-1.5S5.448 5 6 5s1 .672 1 1.5m4 0c0 .828-.448 1.5-1 1.5s-1-.672-1-1.5S9.448 5 10 5s1 .672 1 1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 531 B

4
static/images/envelope-at.svg

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-envelope-at" viewBox="0 0 16 16">
<path d="M2 2a2 2 0 0 0-2 2v8.01A2 2 0 0 0 2 14h5.5a.5.5 0 0 0 0-1H2a1 1 0 0 1-.966-.741l5.64-3.471L8 9.583l7-4.2V8.5a.5.5 0 0 0 1 0V4a2 2 0 0 0-2-2zm3.708 6.208L1 11.105V5.383zM1 4.217V4a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v.217l-7 4.2z"/>
<path d="M14.247 14.269c1.01 0 1.587-.857 1.587-2.025v-.21C15.834 10.43 14.64 9 12.52 9h-.035C10.42 9 9 10.36 9 12.432v.214C9 14.82 10.438 16 12.358 16h.044c.594 0 1.018-.074 1.237-.175v-.73c-.245.11-.673.18-1.18.18h-.044c-1.334 0-2.571-.788-2.571-2.655v-.157c0-1.657 1.058-2.724 2.64-2.724h.04c1.535 0 2.484 1.05 2.484 2.326v.118c0 .975-.324 1.39-.639 1.39-.232 0-.41-.148-.41-.42v-2.19h-.906v.569h-.03c-.084-.298-.368-.63-.954-.63-.778 0-1.259.555-1.259 1.4v.528c0 .892.49 1.434 1.26 1.434.471 0 .896-.227 1.014-.643h.043c.118.42.617.648 1.12.648m-2.453-1.588v-.227c0-.546.227-.791.573-.791.297 0 .572.192.572.708v.367c0 .573-.253.744-.564.744-.354 0-.581-.215-.581-.8Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

4
static/images/info-circle.svg

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-info-circle" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0"/>
</svg>

After

Width:  |  Height:  |  Size: 460 B

4
static/images/key.svg

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-key" viewBox="0 0 16 16">
<path d="M0 8a4 4 0 0 1 7.465-2H14a.5.5 0 0 1 .354.146l1.5 1.5a.5.5 0 0 1 0 .708l-1.5 1.5a.5.5 0 0 1-.708 0L13 9.207l-.646.647a.5.5 0 0 1-.708 0L11 9.207l-.646.647a.5.5 0 0 1-.708 0L9 9.207l-.646.647A.5.5 0 0 1 8 10h-.535A4 4 0 0 1 0 8m4-3a3 3 0 1 0 2.712 4.285A.5.5 0 0 1 7.163 9h.63l.853-.854a.5.5 0 0 1 .708 0l.646.647.646-.647a.5.5 0 0 1 .708 0l.646.647.646-.647a.5.5 0 0 1 .708 0l.646.647.793-.793-1-1h-6.63a.5.5 0 0 1-.451-.285A3 3 0 0 0 4 5"/>
<path d="M4 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0"/>
</svg>

After

Width:  |  Height:  |  Size: 628 B

4
static/images/moon-stars.svg

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-moon-stars" viewBox="0 0 16 16">
<path d="M6 .278a.77.77 0 0 1 .08.858 7.2 7.2 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277q.792-.001 1.533-.16a.79.79 0 0 1 .81.316.73.73 0 0 1-.031.893A8.35 8.35 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.75.75 0 0 1 6 .278M4.858 1.311A7.27 7.27 0 0 0 1.025 7.71c0 4.02 3.279 7.276 7.319 7.276a7.32 7.32 0 0 0 5.205-2.162q-.506.063-1.029.063c-4.61 0-8.343-3.714-8.343-8.29 0-1.167.242-2.278.681-3.286"/>
<path d="M10.794 3.148a.217.217 0 0 1 .412 0l.387 1.162c.173.518.579.924 1.097 1.097l1.162.387a.217.217 0 0 1 0 .412l-1.162.387a1.73 1.73 0 0 0-1.097 1.097l-.387 1.162a.217.217 0 0 1-.412 0l-.387-1.162A1.73 1.73 0 0 0 9.31 6.593l-1.162-.387a.217.217 0 0 1 0-.412l1.162-.387a1.73 1.73 0 0 0 1.097-1.097zM13.863.099a.145.145 0 0 1 .274 0l.258.774c.115.346.386.617.732.732l.774.258a.145.145 0 0 1 0 .274l-.774.258a1.16 1.16 0 0 0-.732.732l-.258.774a.145.145 0 0 1-.274 0l-.258-.774a1.16 1.16 0 0 0-.732-.732l-.774-.258a.145.145 0 0 1 0-.274l.774-.258c.346-.115.617-.386.732-.732z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

3
static/images/universal-access.svg

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-universal-access" viewBox="0 0 16 16">
<path d="M9.5 1.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0M6 5.5l-4.535-.442A.531.531 0 0 1 1.531 4H14.47a.531.531 0 0 1 .066 1.058L10 5.5V9l.452 6.42a.535.535 0 0 1-1.053.174L8.243 9.97c-.064-.252-.422-.252-.486 0l-1.156 5.624a.535.535 0 0 1-1.053-.174L6 9z"/>
</svg>

After

Width:  |  Height:  |  Size: 400 B

Loading…
Cancel
Save