@ -0,0 +1,47 @@
|
||||
all: clean |
||||
mkdir -p bin && \
|
||||
cd src && CGO_ENABLED=0 go build && mv dela ../bin && \
|
||||
cd .. && \
|
||||
cp -r pages bin && \
|
||||
cp -r scripts bin && \
|
||||
cp -r static bin
|
||||
|
||||
portable: clean all |
||||
cd bin/ && cp ../COPYING . && cp ../README.md . && zip -r dela.zip * && mv dela.zip ..
|
||||
|
||||
cross: clean |
||||
mkdir -p bin
|
||||
mkdir -p bin/dela_linux_x64
|
||||
mkdir -p bin/dela_linux_x32
|
||||
mkdir -p bin/dela_windows_x64
|
||||
mkdir -p bin/dela_windows_arm64
|
||||
mkdir -p bin/dela_darwin_x64
|
||||
mkdir -p bin/dela_darwin_arm64
|
||||
mkdir -p bin/dela_freebsd_x64
|
||||
mkdir -p bin/dela_freebsd_arm64
|
||||
|
||||
|
||||
|
||||
cd src && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build && mv dela ../bin/dela_linux_x64
|
||||
cd src && CGO_ENABLED=0 GOOS=linux GOARCH=386 go build && mv dela ../bin/dela_linux_x32
|
||||
cd src && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build && mv dela.exe ../bin/dela_windows_x64
|
||||
cd src && CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build && mv dela.exe ../bin/dela_windows_arm64
|
||||
cd src && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build && mv dela ../bin/dela_darwin_x64
|
||||
cd src && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build && mv dela ../bin/dela_darwin_arm64
|
||||
cd src && CGO_ENABLED=0 GOOS=openbsd GOARCH=amd64 go build && mv dela ../bin/dela_freebsd_x64
|
||||
cd src && CGO_ENABLED=0 GOOS=openbsd GOARCH=arm64 go build && mv dela ../bin/dela_freebsd_arm64
|
||||
|
||||
|
||||
mkdir -p bin/includes
|
||||
|
||||
cp -r pages bin/includes
|
||||
cp -r scripts bin/includes
|
||||
cp -r static bin/includes
|
||||
cp COPYING bin/includes
|
||||
cp LICENSE* bin/includes
|
||||
cp README.md bin/includes
|
||||
|
||||
|
||||
clean: |
||||
rm -rf bin
|
||||
|
@ -0,0 +1,27 @@
|
||||
# Dela dead simple web TODO list |
||||
|
||||
## About |
||||
|
||||
Dela is a web TODO list application which can be hosted on your server and accessed via browser. Current capabilities include: |
||||
|
||||
- Password protected account system |
||||
- TODO creation |
||||
- TODO completion |
||||
|
||||
## Build |
||||
|
||||
``` |
||||
make |
||||
``` |
||||
|
||||
or manually compile with `go build` inside the `src` directory, move the binary and put it alongside `pages`, `scripts` and `static` (although the path to the contents can be specified via configuration file). |
||||
|
||||
``` |
||||
make portable |
||||
``` |
||||
|
||||
will create a `dela.zip` archive with all the content directories and the binary which are to be freely transported to other machines (of the same OS and architecture, obviously) |
||||
|
||||
## Use |
||||
|
||||
After the first run a configuration file will be put alongside the executable, upon the second launch server will start and the service will be accessible on the specified port. |
@ -0,0 +1,9 @@
|
||||
1) Профиль |
||||
2) Теги заметок/Каталоги заметок |
||||
3) Темная/Светлая тема |
||||
4) Drag and Drop |
||||
5) Подтверждение почты |
||||
|
||||
Не SQLite3 |
||||
|
||||
Figma |
@ -0,0 +1,15 @@
|
||||
{{ template "base" . }} |
||||
|
||||
{{ define "content" }} |
||||
<main class="cover-container d-flex w-100 h-100 p-3 mx-auto flex-column"> |
||||
<div class="p-2 flex-fill text-wrap text-center"> |
||||
<h1>Dela.</h1> |
||||
<p class="lead">Simple and elegant web TODO list</p> |
||||
<p class="lead"> |
||||
<a href="/login" class="btn btn-lg btn-primary">Login</a> |
||||
<a href="/register" class="btn btn-lg btn-primary">Register</a> |
||||
</p> |
||||
</div> |
||||
</main> |
||||
|
||||
{{ end }} |
@ -0,0 +1,92 @@
|
||||
{{ define "base" }} |
||||
|
||||
<!DOCTYPE html> |
||||
<html lang="en"> |
||||
|
||||
<head> |
||||
<meta charset="utf-8"> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||
<title>dela</title> |
||||
<link rel="shortcut icon" href="/static/images/favicon.ico" type="image/x-icon"> |
||||
<link rel="stylesheet" href="/static/bootstrap/css/bootstrap.min.css"> |
||||
<script src="/static/bootstrap/js/bootstrap.min.js"></script> |
||||
</head> |
||||
|
||||
<!-- <body class="d-flex flex-column h-100" style="width: 100%;"> --> |
||||
<body class="w-100 h-100"> |
||||
<!-- Header --> |
||||
<!-- <header class="d-flex flex-wrap align-items-center justify-content-center py-3 mb-4 border-bottom"> |
||||
<div class="col-md-3 mb-2 mb-md-0"> |
||||
<a href="/" class="d-inline-flex link-body-emphasis text-decoration-none"> |
||||
<img width="64" height="64" src="/static/images/android-chrome-192x192.png" alt="Dela"> |
||||
</a> |
||||
</div> |
||||
|
||||
<div class="col-md-3 text-end" id="bar-auth"> |
||||
<a href="/login" class="btn btn-outline-primary"><img src="/static/images/door-open-fill.svg" alt="Log in"></a> |
||||
<a href="/register" class="btn btn-outline-primary"><img src="/static/images/person-fill-add.svg" alt="Register"></a> |
||||
</div> |
||||
</header> --> |
||||
<header class="p-3 text-bg-primary"> |
||||
<div class="container"> |
||||
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start"> |
||||
<a href="/" class="d-flex align-items-center mb-2 mb-lg-0 text-white text-decoration-none"> |
||||
<a href="/" class="d-inline-flex link-body-emphasis text-decoration-none"> |
||||
<img width="64" height="64" src="/static/images/android-chrome-192x192.png" alt="Dela"> |
||||
</a> |
||||
</a> |
||||
|
||||
<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0"> |
||||
<li><a href="/register" class="nav-link px-2 text-white">Register</a></li> |
||||
<li><a href="/about" class="nav-link px-2 text-white">About</a></li> |
||||
</ul> |
||||
|
||||
<div class="text-end" id="bar-auth"> |
||||
<a href="/login" class="btn btn-outline-light me-2">Login</a> |
||||
<a href="/register" class="btn btn-warning">Sign-up</a> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</header> |
||||
|
||||
|
||||
|
||||
<!-- Content --> |
||||
{{ template "content" . }} |
||||
|
||||
</body> |
||||
</html> |
||||
|
||||
<script src="/scripts/auth.js"></script> |
||||
<script src="/scripts/api.js"></script> |
||||
<script> |
||||
document.addEventListener('DOMContentLoaded', async function() { |
||||
let login = getLogin(); |
||||
let password = getUserPassword(); |
||||
|
||||
if (login == null | login == "" | password == null | password == "") { |
||||
if (window.location.pathname != "/about" && window.location.pathname != "/login" && window.location.pathname != "/register") { |
||||
window.location.replace("/about"); |
||||
} |
||||
return; |
||||
} |
||||
|
||||
// Check if auth info is indeed valid |
||||
let response = await getUser(login, password); |
||||
if (response.ok) { |
||||
let barAuth = document.getElementById("bar-auth"); |
||||
barAuth.innerHTML = "<b>" + login + "</b>" + " | "; |
||||
barAuth.innerHTML += '<button id="log-out-btn" class="btn btn-outline-light me-2"><img src="/static/images/person-dash-fill.svg"></button>'; |
||||
document.getElementById("log-out-btn").addEventListener("click", (event) => { |
||||
// Log out |
||||
forgetAuthInfo(); |
||||
window.location.replace("/about"); |
||||
}); |
||||
} else { |
||||
forgetAuthInfo(); |
||||
window.location.replace("/about"); |
||||
} |
||||
}, false) |
||||
</script> |
||||
|
||||
{{ end }} |
@ -0,0 +1,270 @@
|
||||
{{ template "base" . }} |
||||
|
||||
{{ define "content" }} |
||||
|
||||
<!-- Main --> |
||||
<main class="d-flex flex-wrap"> |
||||
|
||||
<!-- Sidebar --> |
||||
<div id="sidebar" class="flex-shrink-1 p-2 d-flex flex-column align-items-stretch bg-body-tertiary" style="width: 380px;"> |
||||
<a href="/" class="d-flex align-items-center flex-shrink-0 p-3 link-body-emphasis text-decoration-none border-bottom"> |
||||
<svg class="bi pe-none me-2" width="30" height="24"><use xlink:href="#bootstrap"></use></svg> |
||||
<span class="fs-5 fw-semibold">List group</span> |
||||
</a> |
||||
<div class="list-group list-group-flush border-bottom scrollarea"> |
||||
<a href="#" class="list-group-item list-group-item-action active py-3 lh-sm" aria-current="true"> |
||||
<div class="d-flex w-100 align-items-center justify-content-between"> |
||||
<strong class="mb-1">List group item heading</strong> |
||||
<small>Wed</small> |
||||
</div> |
||||
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div> |
||||
</a> |
||||
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm"> |
||||
<div class="d-flex w-100 align-items-center justify-content-between"> |
||||
<strong class="mb-1">List group item heading</strong> |
||||
<small class="text-body-secondary">Tues</small> |
||||
</div> |
||||
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div> |
||||
</a> |
||||
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm"> |
||||
<div class="d-flex w-100 align-items-center justify-content-between"> |
||||
<strong class="mb-1">List group item heading</strong> |
||||
<small class="text-body-secondary">Mon</small> |
||||
</div> |
||||
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div> |
||||
</a> |
||||
|
||||
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm" aria-current="true"> |
||||
<div class="d-flex w-100 align-items-center justify-content-between"> |
||||
<strong class="mb-1">List group item heading</strong> |
||||
<small class="text-body-secondary">Wed</small> |
||||
</div> |
||||
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div> |
||||
</a> |
||||
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm"> |
||||
<div class="d-flex w-100 align-items-center justify-content-between"> |
||||
<strong class="mb-1">List group item heading</strong> |
||||
<small class="text-body-secondary">Tues</small> |
||||
</div> |
||||
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div> |
||||
</a> |
||||
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm"> |
||||
<div class="d-flex w-100 align-items-center justify-content-between"> |
||||
<strong class="mb-1">List group item heading</strong> |
||||
<small class="text-body-secondary">Mon</small> |
||||
</div> |
||||
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div> |
||||
</a> |
||||
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm" aria-current="true"> |
||||
<div class="d-flex w-100 align-items-center justify-content-between"> |
||||
<strong class="mb-1">List group item heading</strong> |
||||
<small class="text-body-secondary">Wed</small> |
||||
</div> |
||||
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div> |
||||
</a> |
||||
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm"> |
||||
<div class="d-flex w-100 align-items-center justify-content-between"> |
||||
<strong class="mb-1">List group item heading</strong> |
||||
<small class="text-body-secondary">Tues</small> |
||||
</div> |
||||
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div> |
||||
</a> |
||||
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm"> |
||||
<div class="d-flex w-100 align-items-center justify-content-between"> |
||||
<strong class="mb-1">List group item heading</strong> |
||||
<small class="text-body-secondary">Mon</small> |
||||
</div> |
||||
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div> |
||||
</a> |
||||
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm" aria-current="true"> |
||||
<div class="d-flex w-100 align-items-center justify-content-between"> |
||||
<strong class="mb-1">List group item heading</strong> |
||||
<small class="text-body-secondary">Wed</small> |
||||
</div> |
||||
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div> |
||||
</a> |
||||
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm"> |
||||
<div class="d-flex w-100 align-items-center justify-content-between"> |
||||
<strong class="mb-1">List group item heading</strong> |
||||
<small class="text-body-secondary">Tues</small> |
||||
</div> |
||||
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div> |
||||
</a> |
||||
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm"> |
||||
<div class="d-flex w-100 align-items-center justify-content-between"> |
||||
<strong class="mb-1">List group item heading</strong> |
||||
<small class="text-body-secondary">Mon</small> |
||||
</div> |
||||
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div> |
||||
</a> |
||||
</div> |
||||
</div> |
||||
|
||||
|
||||
<div class="p-2 flex-grow-1"> |
||||
<form action="javascript:void(0);"> |
||||
<div class="container"> |
||||
<div class="row"> |
||||
<div class="col"> |
||||
<input type="text" class="form-control" id="new-todo-text" placeholder="TODO text"> |
||||
</div> |
||||
<div class="col"> |
||||
<button id="new-todo-submit" class="btn btn-primary">Add</button> |
||||
<button class="btn btn-secondary" id="show-done">Show Done</button> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</form> |
||||
|
||||
<div class="container text-center"> |
||||
<table id="todos" class="table table-hover" style="word-wrap: break-word;"> |
||||
</table> |
||||
</div> |
||||
|
||||
</div> |
||||
</main> |
||||
|
||||
<script> |
||||
async function displayTodos(showDone) { |
||||
let username = getUsername(); |
||||
let password = getUserPassword(); |
||||
|
||||
// Fetch and display TODOs |
||||
let response = await getTodos(username, password); |
||||
let todosJson = await response.json(); |
||||
let todosDisplayed = []; |
||||
|
||||
if (response.ok && todosJson != null) { |
||||
let todosDiv = document.getElementById("todos"); |
||||
// Clear what we've had before |
||||
todosDiv.innerHTML = ""; |
||||
|
||||
todosJson.forEach((item) => { |
||||
if (showDone === true && item.isDone == true) { |
||||
// An already done Todo |
||||
|
||||
let todoDeleteBtnID = "btn-delete-" + String(item.id); |
||||
|
||||
// Display |
||||
let timeCreated = new Date(item.timeCreatedUnix * 1000); |
||||
let timeDone = new Date(item.completionTimeUnix * 1000); |
||||
todosDiv.innerHTML += "<tr><td>" + item.text + "</td>" + |
||||
"<td>" + " " + timeCreated.getDate() + "/" + (timeCreated.getMonth() + 1) + "/" + timeCreated.getFullYear() + " | " + |
||||
timeDone.getDate() + "/" + (timeDone.getMonth() + 1) + "/" + timeDone.getFullYear() + "</td>" + |
||||
"<td>" + "<button class='btn btn-danger' id='" + |
||||
todoDeleteBtnID + "'><img src='/static/images/trash3-fill.svg'></button></td></tr>"; |
||||
|
||||
|
||||
|
||||
todosDisplayed.push({item: item, buttonDel: todoDeleteBtnID}); |
||||
|
||||
|
||||
} else if (showDone === false && item.isDone == false) { |
||||
// A yet to be done Todo |
||||
|
||||
let todoCompleteBtnID = "btn-complete-" + String(item.id); |
||||
let todoDeleteBtnID = "btn-delete-" + String(item.id); |
||||
let todoEditBtnID = "btn-edit-" + String(item.id); |
||||
|
||||
// Display |
||||
let timeCreated = new Date(item.timeCreatedUnix * 1000); |
||||
todosDiv.innerHTML += "<tr><td>" + item.text + "</td>" + |
||||
"<td>" + " " + timeCreated.getDate() + "/" + (timeCreated.getMonth() + 1) + "/" + timeCreated.getFullYear() + "</td>" + |
||||
"<td><button class='btn btn-success' id='" + todoCompleteBtnID + "'>" + |
||||
"<img src='/static/images/check.svg'></button><button class='btn btn-danger' id='" + |
||||
todoDeleteBtnID + "'><img src='/static/images/trash3-fill.svg'></button></td></tr>"; |
||||
|
||||
todosDisplayed.push({item: item, buttonDel: todoDeleteBtnID, buttonComplete: todoCompleteBtnID}); |
||||
|
||||
} |
||||
}); |
||||
} |
||||
|
||||
|
||||
// Loop over all buttons (doesn't matter which ones because the amounts are equal) |
||||
for (let i = 0; i < todosDisplayed.length; i++) { |
||||
let elem = todosDisplayed[i]; |
||||
|
||||
if (showDone === false && elem.item.isDone === false) { |
||||
// Done button |
||||
document.getElementById(elem.buttonComplete).addEventListener("click", async (event) => { |
||||
// Mark as done |
||||
elem.item.isDone = true; |
||||
// Set completion time |
||||
elem.item.completionTimeUnix = Math.floor(Date.now() / 1000); |
||||
|
||||
// Update |
||||
response = await updateTodo(username, password, elem.item.id, elem.item); |
||||
if (response.ok) { |
||||
location.reload(); |
||||
} |
||||
}); |
||||
|
||||
// Delete button |
||||
document.getElementById(elem.buttonDel).addEventListener("click", async (event) => { |
||||
response = await deleteTodo(username, password, elem.item.id); |
||||
if (response.ok) { |
||||
location.reload(); |
||||
} |
||||
}); |
||||
} else { |
||||
// Delete button |
||||
document.getElementById(elem.buttonDel).addEventListener("click", async (event) => { |
||||
response = await deleteTodo(username, password, elem.item.id); |
||||
if (response.ok) { |
||||
location.reload(); |
||||
} |
||||
}); |
||||
} |
||||
} |
||||
} |
||||
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function() { |
||||
let username = getUsername(); |
||||
let password = getUserPassword(); |
||||
|
||||
document.getElementById("new-todo-text").focus(); |
||||
|
||||
|
||||
let showDoneButton = document.getElementById("show-done"); |
||||
showDoneButton.addEventListener("click", (event) => { |
||||
displayTodos(true); // Re-display without reloading |
||||
|
||||
// Rename the button |
||||
showDoneButton.innerText = "Show To Do"; |
||||
showDoneButton.className = "btn btn-success"; |
||||
|
||||
// Make it "reset to default" |
||||
showDoneButton.addEventListener("click", (event) => { |
||||
location.reload(); |
||||
}); |
||||
}); |
||||
|
||||
|
||||
// "Add" button |
||||
document.getElementById("new-todo-submit").addEventListener("click", async (event) => { |
||||
let newTodoTextInput = document.getElementById("new-todo-text"); |
||||
let newTodoText = newTodoTextInput.value; |
||||
if (newTodoText.length < 1) { |
||||
newTodoTextInput.setCustomValidity("At least one character is needed!"); |
||||
return; |
||||
} else { |
||||
newTodoTextInput.setCustomValidity(""); |
||||
} |
||||
newTodoTextInput.value = ""; |
||||
|
||||
// Make a request |
||||
let response = await postNewTodo(username, password, {text: newTodoText}); |
||||
if (response.ok) { |
||||
location.reload(); |
||||
} |
||||
}); |
||||
|
||||
|
||||
// Fetch and display TODOs |
||||
await displayTodos(false); |
||||
}, false) |
||||
</script> |
||||
|
||||
{{ end }} |
@ -0,0 +1,58 @@
|
||||
{{ template "base" . }} |
||||
|
||||
{{ define "content" }} |
||||
|
||||
|
||||
<main class="d-flex flex-wrap align-content-center align-items-center"> |
||||
<div class="p-2 flex-fill text-wrap text-center"> |
||||
<!-- text-center --> |
||||
<h3>Log in</h3> |
||||
<form name="loginForm" onsubmit="return false;"> |
||||
<p> |
||||
<label for="login" class="form-label">Login</label> <br> |
||||
<input type="text" name="login" minlength="3" placeholder="Login" required> |
||||
</p> |
||||
|
||||
<p> |
||||
<label for="password" class="form-label">Password</label> <br> |
||||
<input type="password" name="password" minlength="3" placeholder="Password" required> |
||||
</p> |
||||
|
||||
<p><span id="error_message" class="text-danger"></span></p> |
||||
|
||||
<p> |
||||
<input type="submit" value="Log in" class="btn btn-primary" onclick="logIn()"> |
||||
</p> |
||||
</form> |
||||
|
||||
</div> |
||||
</main> |
||||
|
||||
<script> |
||||
async function logIn() { |
||||
let loginForm = document.forms["loginForm"]; |
||||
|
||||
let login = String(loginForm.elements["login"].value).trim(); |
||||
if (login.length < 3) { |
||||
return; |
||||
} |
||||
|
||||
let password = String(loginForm.elements["password"].value); |
||||
if (password.length < 3) { |
||||
return; |
||||
} |
||||
password = sha256(password); |
||||
|
||||
// Check if auth info is indeed valid |
||||
let response = await getUser(login, password); |
||||
|
||||
if (response.ok) { |
||||
rememberAuthInfo(login, password); |
||||
window.location.replace("/"); |
||||
} else { |
||||
document.getElementById("error_message").innerText = await response.text(); |
||||
} |
||||
} |
||||
|
||||
</script> |
||||
{{ end }} |
@ -0,0 +1,69 @@
|
||||
{{ template "base" . }} |
||||
|
||||
{{ define "content" }} |
||||
|
||||
<main class="d-flex flex-wrap align-content-center"> |
||||
<div class="p-2 flex-fill text-wrap text-center"> |
||||
<h3>Register</h3> |
||||
<form name="registerForm" onsubmit="return false;"> |
||||
<p> |
||||
<label for="login" class="form-label">Login</label> <br> |
||||
<input type="text" name="login" minlength="3" placeholder="Login" required> |
||||
</p> |
||||
|
||||
<p> |
||||
<label for="email" class="form-label">Email</label> <br> |
||||
<input type="email" name="email" minlength="3" placeholder="login@example.com" required> |
||||
</p> |
||||
|
||||
<p> |
||||
<label for="password" class="form-label">Password</label> <br> |
||||
<input type="password" name="password" minlength="3" placeholder="Password" required> |
||||
</p> |
||||
|
||||
<p><span id="error_message" class="text-danger"></span></p> |
||||
|
||||
<p> |
||||
<input type="submit" value="Register" class="btn btn-primary" onclick="register();"> |
||||
</p> |
||||
</form> |
||||
</div> |
||||
</main> |
||||
|
||||
<script> |
||||
async function register() { |
||||
let registerForm = document.forms["registerForm"]; |
||||
|
||||
let login = String(registerForm.elements["login"].value).trim(); |
||||
if (login.length < 3) { |
||||
return; |
||||
} |
||||
|
||||
let email = String(registerForm.elements["email"].value).trim(); |
||||
if (email.length < 3) { |
||||
return; |
||||
} |
||||
|
||||
let password = String(registerForm.elements["password"].value); |
||||
if (password.length < 3) { |
||||
return; |
||||
} |
||||
|
||||
let passwordSHA256 = sha256(password); |
||||
let postData = { |
||||
login: login, |
||||
email: email, |
||||
password: passwordSHA256, |
||||
}; |
||||
|
||||
let response = await postNewUser(postData); |
||||
|
||||
if (response.ok) { |
||||
rememberAuthInfo(postData.login, postData.password); |
||||
window.location.replace("/"); |
||||
} else { |
||||
document.getElementById("error_message").innerText = await response.text(); |
||||
} |
||||
}; |
||||
</script> |
||||
{{ end }} |
@ -0,0 +1,270 @@
|
||||
{{ template "base" . }} |
||||
|
||||
{{ define "content" }} |
||||
|
||||
<!-- Sidebar --> |
||||
<div id="sidebar" class="flex-shrink-1 p-2 d-flex flex-column align-items-stretch bg-body-tertiary" style="width: 380px;"> |
||||
<a href="/" class="d-flex align-items-center flex-shrink-0 p-3 link-body-emphasis text-decoration-none border-bottom"> |
||||
<svg class="bi pe-none me-2" width="30" height="24"><use xlink:href="#bootstrap"></use></svg> |
||||
<span class="fs-5 fw-semibold">List group</span> |
||||
</a> |
||||
<div class="list-group list-group-flush border-bottom scrollarea"> |
||||
<a href="#" class="list-group-item list-group-item-action active py-3 lh-sm" aria-current="true"> |
||||
<div class="d-flex w-100 align-items-center justify-content-between"> |
||||
<strong class="mb-1">List group item heading</strong> |
||||
<small>Wed</small> |
||||
</div> |
||||
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div> |
||||
</a> |
||||
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm"> |
||||
<div class="d-flex w-100 align-items-center justify-content-between"> |
||||
<strong class="mb-1">List group item heading</strong> |
||||
<small class="text-body-secondary">Tues</small> |
||||
</div> |
||||
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div> |
||||
</a> |
||||
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm"> |
||||
<div class="d-flex w-100 align-items-center justify-content-between"> |
||||
<strong class="mb-1">List group item heading</strong> |
||||
<small class="text-body-secondary">Mon</small> |
||||
</div> |
||||
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div> |
||||
</a> |
||||
|
||||
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm" aria-current="true"> |
||||
<div class="d-flex w-100 align-items-center justify-content-between"> |
||||
<strong class="mb-1">List group item heading</strong> |
||||
<small class="text-body-secondary">Wed</small> |
||||
</div> |
||||
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div> |
||||
</a> |
||||
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm"> |
||||
<div class="d-flex w-100 align-items-center justify-content-between"> |
||||
<strong class="mb-1">List group item heading</strong> |
||||
<small class="text-body-secondary">Tues</small> |
||||
</div> |
||||
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div> |
||||
</a> |
||||
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm"> |
||||
<div class="d-flex w-100 align-items-center justify-content-between"> |
||||
<strong class="mb-1">List group item heading</strong> |
||||
<small class="text-body-secondary">Mon</small> |
||||
</div> |
||||
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div> |
||||
</a> |
||||
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm" aria-current="true"> |
||||
<div class="d-flex w-100 align-items-center justify-content-between"> |
||||
<strong class="mb-1">List group item heading</strong> |
||||
<small class="text-body-secondary">Wed</small> |
||||
</div> |
||||
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div> |
||||
</a> |
||||
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm"> |
||||
<div class="d-flex w-100 align-items-center justify-content-between"> |
||||
<strong class="mb-1">List group item heading</strong> |
||||
<small class="text-body-secondary">Tues</small> |
||||
</div> |
||||
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div> |
||||
</a> |
||||
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm"> |
||||
<div class="d-flex w-100 align-items-center justify-content-between"> |
||||
<strong class="mb-1">List group item heading</strong> |
||||
<small class="text-body-secondary">Mon</small> |
||||
</div> |
||||
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div> |
||||
</a> |
||||
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm" aria-current="true"> |
||||
<div class="d-flex w-100 align-items-center justify-content-between"> |
||||
<strong class="mb-1">List group item heading</strong> |
||||
<small class="text-body-secondary">Wed</small> |
||||
</div> |
||||
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div> |
||||
</a> |
||||
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm"> |
||||
<div class="d-flex w-100 align-items-center justify-content-between"> |
||||
<strong class="mb-1">List group item heading</strong> |
||||
<small class="text-body-secondary">Tues</small> |
||||
</div> |
||||
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div> |
||||
</a> |
||||
<a href="#" class="list-group-item list-group-item-action py-3 lh-sm"> |
||||
<div class="d-flex w-100 align-items-center justify-content-between"> |
||||
<strong class="mb-1">List group item heading</strong> |
||||
<small class="text-body-secondary">Mon</small> |
||||
</div> |
||||
<div class="col-10 mb-1 small">Some placeholder content in a paragraph below the heading and date.</div> |
||||
</a> |
||||
</div> |
||||
</div> |
||||
|
||||
|
||||
<form action="javascript:void(0);"> |
||||
<div class="container text-center"> |
||||
<div class="row"> |
||||
<div class="col"> |
||||
<input type="text" class="form-control" id="new-todo-text" placeholder="TODO text"> |
||||
</div> |
||||
<div class="col"> |
||||
<button id="new-todo-submit" class="btn btn-primary">Add</button> |
||||
<button class="btn btn-secondary" id="show-done">Show Done</button> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</form> |
||||
|
||||
|
||||
<div class="container text-center" style="margin-top: 4ch;"> |
||||
<table id="todos" class="table table-hover" style="word-wrap: break-word;"> |
||||
{{ for .Todos }} |
||||
<tr> |
||||
{{ .Name }} |
||||
</tr> |
||||
{{ end }} |
||||
</table> |
||||
</div> |
||||
|
||||
|
||||
<script> |
||||
async function displayTodos(showDone) { |
||||
let username = getUsername(); |
||||
let password = getUserPassword(); |
||||
|
||||
// Fetch and display TODOs |
||||
let response = await getTodos(username, password); |
||||
let todosJson = await response.json(); |
||||
let todosDisplayed = []; |
||||
|
||||
if (response.ok && todosJson != null) { |
||||
let todosDiv = document.getElementById("todos"); |
||||
// Clear what we've had before |
||||
todosDiv.innerHTML = ""; |
||||
|
||||
todosJson.forEach((item) => { |
||||
if (showDone === true && item.isDone == true) { |
||||
// An already done Todo |
||||
|
||||
let todoDeleteBtnID = "btn-delete-" + String(item.id); |
||||
|
||||
// Display |
||||
let timeCreated = new Date(item.timeCreatedUnix * 1000); |
||||
let timeDone = new Date(item.completionTimeUnix * 1000); |
||||
todosDiv.innerHTML += "<tr><td>" + item.text + "</td>" + |
||||
"<td>" + " " + timeCreated.getDate() + "/" + (timeCreated.getMonth() + 1) + "/" + timeCreated.getFullYear() + " | " + |
||||
timeDone.getDate() + "/" + (timeDone.getMonth() + 1) + "/" + timeDone.getFullYear() + "</td>" + |
||||
"<td>" + "<button class='btn btn-danger' id='" + |
||||
todoDeleteBtnID + "'><img src='/static/images/trash3-fill.svg'></button></td></tr>"; |
||||
|
||||
|
||||
|
||||
todosDisplayed.push({item: item, buttonDel: todoDeleteBtnID}); |
||||
|
||||
|
||||
} else if (showDone === false && item.isDone == false) { |
||||
// A yet to be done Todo |
||||
|
||||
let todoCompleteBtnID = "btn-complete-" + String(item.id); |
||||
let todoDeleteBtnID = "btn-delete-" + String(item.id); |
||||
let todoEditBtnID = "btn-edit-" + String(item.id); |
||||
|
||||
// Display |
||||
let timeCreated = new Date(item.timeCreatedUnix * 1000); |
||||
todosDiv.innerHTML += "<tr><td>" + item.text + "</td>" + |
||||
"<td>" + " " + timeCreated.getDate() + "/" + (timeCreated.getMonth() + 1) + "/" + timeCreated.getFullYear() + "</td>" + |
||||
"<td><button class='btn btn-success' id='" + todoCompleteBtnID + "'>" + |
||||
"<img src='/static/images/check.svg'></button><button class='btn btn-danger' id='" + |
||||
todoDeleteBtnID + "'><img src='/static/images/trash3-fill.svg'></button></td></tr>"; |
||||
|
||||
todosDisplayed.push({item: item, buttonDel: todoDeleteBtnID, buttonComplete: todoCompleteBtnID}); |
||||
|
||||
} |
||||
}); |
||||
} |
||||
|
||||
|
||||
// Loop over all buttons (doesn't matter which ones because the amounts are equal) |
||||
for (let i = 0; i < todosDisplayed.length; i++) { |
||||
let elem = todosDisplayed[i]; |
||||
|
||||
if (showDone === false && elem.item.isDone === false) { |
||||
// Done button |
||||
document.getElementById(elem.buttonComplete).addEventListener("click", async (event) => { |
||||
// Mark as done |
||||
elem.item.isDone = true; |
||||
// Set completion time |
||||
elem.item.completionTimeUnix = Math.floor(Date.now() / 1000); |
||||
|
||||
// Update |
||||
response = await updateTodo(username, password, elem.item.id, elem.item); |
||||
if (response.ok) { |
||||
location.reload(); |
||||
} |
||||
}); |
||||
|
||||
// Delete button |
||||
document.getElementById(elem.buttonDel).addEventListener("click", async (event) => { |
||||
response = await deleteTodo(username, password, elem.item.id); |
||||
if (response.ok) { |
||||
location.reload(); |
||||
} |
||||
}); |
||||
} else { |
||||
// Delete button |
||||
document.getElementById(elem.buttonDel).addEventListener("click", async (event) => { |
||||
response = await deleteTodo(username, password, elem.item.id); |
||||
if (response.ok) { |
||||
location.reload(); |
||||
} |
||||
}); |
||||
} |
||||
} |
||||
} |
||||
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function() { |
||||
let username = getUsername(); |
||||
let password = getUserPassword(); |
||||
|
||||
document.getElementById("new-todo-text").focus(); |
||||
|
||||
|
||||
let showDoneButton = document.getElementById("show-done"); |
||||
showDoneButton.addEventListener("click", (event) => { |
||||
displayTodos(true); // Re-display without reloading |
||||
|
||||
// Rename the button |
||||
showDoneButton.innerText = "Show To Do"; |
||||
showDoneButton.className = "btn btn-success"; |
||||
|
||||
// Make it "reset to default" |
||||
showDoneButton.addEventListener("click", (event) => { |
||||
location.reload(); |
||||
}); |
||||
}); |
||||
|
||||
|
||||
// "Add" button |
||||
document.getElementById("new-todo-submit").addEventListener("click", async (event) => { |
||||
let newTodoTextInput = document.getElementById("new-todo-text"); |
||||
let newTodoText = newTodoTextInput.value; |
||||
if (newTodoText.length < 1) { |
||||
newTodoTextInput.setCustomValidity("At least one character is needed!"); |
||||
return; |
||||
} else { |
||||
newTodoTextInput.setCustomValidity(""); |
||||
} |
||||
newTodoTextInput.value = ""; |
||||
|
||||
// Make a request |
||||
let response = await postNewTodo(username, password, {text: newTodoText}); |
||||
if (response.ok) { |
||||
location.reload(); |
||||
} |
||||
}); |
||||
|
||||
|
||||
// Fetch and display TODOs |
||||
await displayTodos(false); |
||||
}, false) |
||||
</script> |
||||
|
||||
{{ end }} |
@ -0,0 +1,83 @@
|
||||
/* |
||||
2023 Kasyanov Nikolay Alexeyevich (Unbewohnte) |
||||
*/ |
||||
|
||||
|
||||
async function post(url, login, password, json) { |
||||
return fetch(url, { |
||||
method: "POST", |
||||
headers: { |
||||
"Authorization": "Basic " + btoa(login + ":" + password), |
||||
"Content-Type": "application/json", |
||||
}, |
||||
body: JSON.stringify(json) |
||||
}) |
||||
} |
||||
|
||||
async function postNewTodo(login, password, newTodo) { |
||||
return post("/api/todo/create", login, password, newTodo) |
||||
} |
||||
|
||||
async function postNewGroup(login, password, newGroup) { |
||||
return post("/api/group/create", login, password, newGroup) |
||||
} |
||||
|
||||
async function postNewUser(newUser) { |
||||
return post("/api/user/create", "", "", newUser) |
||||
} |
||||
|
||||
async function get(url, login, password) { |
||||
return fetch(url, { |
||||
method: "GET", |
||||
headers: { |
||||
"Authorization": "Basic " + btoa(login + ":" + password), |
||||
"Content-Type": "application/json", |
||||
}, |
||||
}) |
||||
} |
||||
|
||||
async function getUser(login, password) { |
||||
return get("/api/user/get", login, password); |
||||
} |
||||
|
||||
async function getTodos(login, password) { |
||||
return get("/api/todo/get", login, password); |
||||
} |
||||
|
||||
async function getGroup(login, password) { |
||||
return get("/api/group/get", login, password); |
||||
} |
||||
|
||||
async function getAllGroups(login, password) { |
||||
return get("/api/user/get", login, password); |
||||
} |
||||
|
||||
async function del(url, login, password) { |
||||
return fetch(url, { |
||||
method: "DELETE", |
||||
headers: { |
||||
"Authorization": "Basic " + btoa(login + ":" + password), |
||||
"Content-Type": "application/json", |
||||
}, |
||||
}) |
||||
} |
||||
|
||||
async function deleteTodo(login, password, id) { |
||||
return del("/api/todo/delete/"+id, login, password); |
||||
} |
||||
|
||||
async function update(url, login, password, json) { |
||||
return post(url, login, password, json); |
||||
} |
||||
|
||||
async function updateTodo(login, password, id, updatedTodo) { |
||||
return update("/api/todo/update/"+id, login, password, updatedTodo); |
||||
} |
||||
|
||||
async function updateGroup(login, password, id, updatedGroup) { |
||||
return update("/api/group/update/"+id, login, password, updateGroup); |
||||
} |
||||
|
||||
async function updateUser(login, password, updatedUser) { |
||||
return update("/api/group/update/"+login, login, password, updatedUser); |
||||
} |
@ -0,0 +1,545 @@
|
||||
/* |
||||
2023 Kasyanov Nikolay Alexeyevich (Unbewohnte) |
||||
*/ |
||||
|
||||
|
||||
// Saves auth information to local storage
|
||||
function rememberAuthInfo(login, password) { |
||||
localStorage.setItem("login", login); |
||||
localStorage.setItem("password", password); |
||||
} |
||||
|
||||
// Retrieves user's password from local storage
|
||||
function getUserPassword() { |
||||
return localStorage.getItem("password"); |
||||
} |
||||
|
||||
// Retrieves user's login from local storage
|
||||
function getLogin() { |
||||
return localStorage.getItem("login"); |
||||
} |
||||
|
||||
// Removes all auth information from local storage
|
||||
function forgetAuthInfo() { |
||||
localStorage.removeItem("login"); |
||||
localStorage.removeItem("password"); |
||||
} |
||||
|
||||
/** |
||||
* [js-sha256]{@link https://github.com/emn178/js-sha256}
|
||||
* |
||||
* @version 0.9.0 |
||||
* @author Chen, Yi-Cyuan [emn178@gmail.com] |
||||
* @copyright Chen, Yi-Cyuan 2014-2017 |
||||
* @license MIT |
||||
*/ |
||||
/*jslint bitwise: true */ |
||||
(function () { |
||||
'use strict'; |
||||
|
||||
var ERROR = 'input is invalid type'; |
||||
var WINDOW = typeof window === 'object'; |
||||
var root = WINDOW ? window : {}; |
||||
if (root.JS_SHA256_NO_WINDOW) { |
||||
WINDOW = false; |
||||
} |
||||
var WEB_WORKER = !WINDOW && typeof self === 'object'; |
||||
var NODE_JS = !root.JS_SHA256_NO_NODE_JS && typeof process === 'object' && process.versions && process.versions.node; |
||||
if (NODE_JS) { |
||||
root = global; |
||||
} else if (WEB_WORKER) { |
||||
root = self; |
||||
} |
||||
var COMMON_JS = !root.JS_SHA256_NO_COMMON_JS && typeof module === 'object' && module.exports; |
||||
var AMD = typeof define === 'function' && define.amd; |
||||
var ARRAY_BUFFER = !root.JS_SHA256_NO_ARRAY_BUFFER && typeof ArrayBuffer !== 'undefined'; |
||||
var HEX_CHARS = '0123456789abcdef'.split(''); |
||||
var EXTRA = [-2147483648, 8388608, 32768, 128]; |
||||
var SHIFT = [24, 16, 8, 0]; |
||||
var K = [ |
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, |
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, |
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, |
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, |
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, |
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, |
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, |
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 |
||||
]; |
||||
var OUTPUT_TYPES = ['hex', 'array', 'digest', 'arrayBuffer']; |
||||
|
||||
var blocks = []; |
||||
|
||||
if (root.JS_SHA256_NO_NODE_JS || !Array.isArray) { |
||||
Array.isArray = function (obj) { |
||||
return Object.prototype.toString.call(obj) === '[object Array]'; |
||||
}; |
||||
} |
||||
|
||||
if (ARRAY_BUFFER && (root.JS_SHA256_NO_ARRAY_BUFFER_IS_VIEW || !ArrayBuffer.isView)) { |
||||
ArrayBuffer.isView = function (obj) { |
||||
return typeof obj === 'object' && obj.buffer && obj.buffer.constructor === ArrayBuffer; |
||||
}; |
||||
} |
||||
|
||||
var createOutputMethod = function (outputType, is224) { |
||||
return function (message) { |
||||
return new Sha256(is224, true).update(message)[outputType](); |
||||
}; |
||||
}; |
||||
|
||||
var createMethod = function (is224) { |
||||
var method = createOutputMethod('hex', is224); |
||||
if (NODE_JS) { |
||||
method = nodeWrap(method, is224); |
||||
} |
||||
method.create = function () { |
||||
return new Sha256(is224); |
||||
}; |
||||
method.update = function (message) { |
||||
return method.create().update(message); |
||||
}; |
||||
for (var i = 0; i < OUTPUT_TYPES.length; ++i) { |
||||
var type = OUTPUT_TYPES[i]; |
||||
method[type] = createOutputMethod(type, is224); |
||||
} |
||||
return method; |
||||
}; |
||||
|
||||
var nodeWrap = function (method, is224) { |
||||
var crypto = eval("require('crypto')"); |
||||
var Buffer = eval("require('buffer').Buffer"); |
||||
var algorithm = is224 ? 'sha224' : 'sha256'; |
||||
var nodeMethod = function (message) { |
||||
if (typeof message === 'string') { |
||||
return crypto.createHash(algorithm).update(message, 'utf8').digest('hex'); |
||||
} else { |
||||
if (message === null || message === undefined) { |
||||
throw new Error(ERROR); |
||||
} else if (message.constructor === ArrayBuffer) { |
||||
message = new Uint8Array(message); |
||||
} |
||||
} |
||||
if (Array.isArray(message) || ArrayBuffer.isView(message) || |
||||
message.constructor === Buffer) { |
||||
return crypto.createHash(algorithm).update(new Buffer(message)).digest('hex'); |
||||
} else { |
||||
return method(message); |
||||
} |
||||
}; |
||||
return nodeMethod; |
||||
}; |
||||
|
||||
var createHmacOutputMethod = function (outputType, is224) { |
||||
return function (key, message) { |
||||
return new HmacSha256(key, is224, true).update(message)[outputType](); |
||||
}; |
||||
}; |
||||
|
||||
var createHmacMethod = function (is224) { |
||||
var method = createHmacOutputMethod('hex', is224); |
||||
method.create = function (key) { |
||||
return new HmacSha256(key, is224); |
||||
}; |
||||
method.update = function (key, message) { |
||||
return method.create(key).update(message); |
||||
}; |
||||
for (var i = 0; i < OUTPUT_TYPES.length; ++i) { |
||||
var type = OUTPUT_TYPES[i]; |
||||
method[type] = createHmacOutputMethod(type, is224); |
||||
} |
||||
return method; |
||||
}; |
||||
|
||||
function Sha256(is224, sharedMemory) { |
||||
if (sharedMemory) { |
||||
blocks[0] = blocks[16] = blocks[1] = blocks[2] = blocks[3] = |
||||
blocks[4] = blocks[5] = blocks[6] = blocks[7] = |
||||
blocks[8] = blocks[9] = blocks[10] = blocks[11] = |
||||
blocks[12] = blocks[13] = blocks[14] = blocks[15] = 0; |
||||
this.blocks = blocks; |
||||
} else { |
||||
this.blocks = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; |
||||
} |
||||
|
||||
if (is224) { |
||||
this.h0 = 0xc1059ed8; |
||||
this.h1 = 0x367cd507; |
||||
this.h2 = 0x3070dd17; |
||||
this.h3 = 0xf70e5939; |
||||
this.h4 = 0xffc00b31; |
||||
this.h5 = 0x68581511; |
||||
this.h6 = 0x64f98fa7; |
||||
this.h7 = 0xbefa4fa4; |
||||
} else { // 256
|
||||
this.h0 = 0x6a09e667; |
||||
this.h1 = 0xbb67ae85; |
||||
this.h2 = 0x3c6ef372; |
||||
this.h3 = 0xa54ff53a; |
||||
this.h4 = 0x510e527f; |
||||
this.h5 = 0x9b05688c; |
||||
this.h6 = 0x1f83d9ab; |
||||
this.h7 = 0x5be0cd19; |
||||
} |
||||
|
||||
this.block = this.start = this.bytes = this.hBytes = 0; |
||||
this.finalized = this.hashed = false; |
||||
this.first = true; |
||||
this.is224 = is224; |
||||
} |
||||
|
||||
Sha256.prototype.update = function (message) { |
||||
if (this.finalized) { |
||||
return; |
||||
} |
||||
var notString, type = typeof message; |
||||
if (type !== 'string') { |
||||
if (type === 'object') { |
||||
if (message === null) { |
||||
throw new Error(ERROR); |
||||
} else if (ARRAY_BUFFER && message.constructor === ArrayBuffer) { |
||||
message = new Uint8Array(message); |
||||
} else if (!Array.isArray(message)) { |
||||
if (!ARRAY_BUFFER || !ArrayBuffer.isView(message)) { |
||||
throw new Error(ERROR); |
||||
} |
||||
} |
||||
} else { |
||||
throw new Error(ERROR); |
||||
} |
||||
notString = true; |
||||
} |
||||
var code, index = 0, i, length = message.length, blocks = this.blocks; |
||||
|
||||
while (index < length) { |
||||
if (this.hashed) { |
||||
this.hashed = false; |
||||
blocks[0] = this.block; |
||||
blocks[16] = blocks[1] = blocks[2] = blocks[3] = |
||||
blocks[4] = blocks[5] = blocks[6] = blocks[7] = |
||||
blocks[8] = blocks[9] = blocks[10] = blocks[11] = |
||||
blocks[12] = blocks[13] = blocks[14] = blocks[15] = 0; |
||||
} |
||||
|
||||
if (notString) { |
||||
for (i = this.start; index < length && i < 64; ++index) { |
||||
blocks[i >> 2] |= message[index] << SHIFT[i++ & 3]; |
||||
} |
||||
} else { |
||||
for (i = this.start; index < length && i < 64; ++index) { |
||||
code = message.charCodeAt(index); |
||||
if (code < 0x80) { |
||||
blocks[i >> 2] |= code << SHIFT[i++ & 3]; |
||||
} else if (code < 0x800) { |
||||
blocks[i >> 2] |= (0xc0 | (code >> 6)) << SHIFT[i++ & 3]; |
||||
blocks[i >> 2] |= (0x80 | (code & 0x3f)) << SHIFT[i++ & 3]; |
||||
} else if (code < 0xd800 || code >= 0xe000) { |
||||
blocks[i >> 2] |= (0xe0 | (code >> 12)) << SHIFT[i++ & 3]; |
||||
blocks[i >> 2] |= (0x80 | ((code >> 6) & 0x3f)) << SHIFT[i++ & 3]; |
||||
blocks[i >> 2] |= (0x80 | (code & 0x3f)) << SHIFT[i++ & 3]; |
||||
} else { |
||||
code = 0x10000 + (((code & 0x3ff) << 10) | (message.charCodeAt(++index) & 0x3ff)); |
||||
blocks[i >> 2] |= (0xf0 | (code >> 18)) << SHIFT[i++ & 3]; |
||||
blocks[i >> 2] |= (0x80 | ((code >> 12) & 0x3f)) << SHIFT[i++ & 3]; |
||||
blocks[i >> 2] |= (0x80 | ((code >> 6) & 0x3f)) << SHIFT[i++ & 3]; |
||||
blocks[i >> 2] |= (0x80 | (code & 0x3f)) << SHIFT[i++ & 3]; |
||||
} |
||||
} |
||||
} |
||||
|
||||
this.lastByteIndex = i; |
||||
this.bytes += i - this.start; |
||||
if (i >= 64) { |
||||
this.block = blocks[16]; |
||||
this.start = i - 64; |
||||
this.hash(); |
||||
this.hashed = true; |
||||
} else { |
||||
this.start = i; |
||||
} |
||||
} |
||||
if (this.bytes > 4294967295) { |
||||
this.hBytes += this.bytes / 4294967296 << 0; |
||||
this.bytes = this.bytes % 4294967296; |
||||
} |
||||
return this; |
||||
}; |
||||
|
||||
Sha256.prototype.finalize = function () { |
||||
if (this.finalized) { |
||||
return; |
||||
} |
||||
this.finalized = true; |
||||
var blocks = this.blocks, i = this.lastByteIndex; |
||||
blocks[16] = this.block; |
||||
blocks[i >> 2] |= EXTRA[i & 3]; |
||||
this.block = blocks[16]; |
||||
if (i >= 56) { |
||||
if (!this.hashed) { |
||||
this.hash(); |
||||
} |
||||
blocks[0] = this.block; |
||||
blocks[16] = blocks[1] = blocks[2] = blocks[3] = |
||||
blocks[4] = blocks[5] = blocks[6] = blocks[7] = |
||||
blocks[8] = blocks[9] = blocks[10] = blocks[11] = |
||||
blocks[12] = blocks[13] = blocks[14] = blocks[15] = 0; |
||||
} |
||||
blocks[14] = this.hBytes << 3 | this.bytes >>> 29; |
||||
blocks[15] = this.bytes << 3; |
||||
this.hash(); |
||||
}; |
||||
|
||||
Sha256.prototype.hash = function () { |
||||
var a = this.h0, b = this.h1, c = this.h2, d = this.h3, e = this.h4, f = this.h5, g = this.h6, |
||||
h = this.h7, blocks = this.blocks, j, s0, s1, maj, t1, t2, ch, ab, da, cd, bc; |
||||
|
||||
for (j = 16; j < 64; ++j) { |
||||
// rightrotate
|
||||
t1 = blocks[j - 15]; |
||||
s0 = ((t1 >>> 7) | (t1 << 25)) ^ ((t1 >>> 18) | (t1 << 14)) ^ (t1 >>> 3); |
||||
t1 = blocks[j - 2]; |
||||
s1 = ((t1 >>> 17) | (t1 << 15)) ^ ((t1 >>> 19) | (t1 << 13)) ^ (t1 >>> 10); |
||||
blocks[j] = blocks[j - 16] + s0 + blocks[j - 7] + s1 << 0; |
||||
} |
||||
|
||||
bc = b & c; |
||||
for (j = 0; j < 64; j += 4) { |
||||
if (this.first) { |
||||
if (this.is224) { |
||||
ab = 300032; |
||||
t1 = blocks[0] - 1413257819; |
||||
h = t1 - 150054599 << 0; |
||||
d = t1 + 24177077 << 0; |
||||
} else { |
||||
ab = 704751109; |
||||
t1 = blocks[0] - 210244248; |
||||
h = t1 - 1521486534 << 0; |
||||
d = t1 + 143694565 << 0; |
||||
} |
||||
this.first = false; |
||||
} else { |
||||
s0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10)); |
||||
s1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7)); |
||||
ab = a & b; |
||||
maj = ab ^ (a & c) ^ bc; |
||||
ch = (e & f) ^ (~e & g); |
||||
t1 = h + s1 + ch + K[j] + blocks[j]; |
||||
t2 = s0 + maj; |
||||
h = d + t1 << 0; |
||||
d = t1 + t2 << 0; |
||||
} |
||||
s0 = ((d >>> 2) | (d << 30)) ^ ((d >>> 13) | (d << 19)) ^ ((d >>> 22) | (d << 10)); |
||||
s1 = ((h >>> 6) | (h << 26)) ^ ((h >>> 11) | (h << 21)) ^ ((h >>> 25) | (h << 7)); |
||||
da = d & a; |
||||
maj = da ^ (d & b) ^ ab; |
||||
ch = (h & e) ^ (~h & f); |
||||
t1 = g + s1 + ch + K[j + 1] + blocks[j + 1]; |
||||
t2 = s0 + maj; |
||||
g = c + t1 << 0; |
||||
c = t1 + t2 << 0; |
||||
s0 = ((c >>> 2) | (c << 30)) ^ ((c >>> 13) | (c << 19)) ^ ((c >>> 22) | (c << 10)); |
||||
s1 = ((g >>> 6) | (g << 26)) ^ ((g >>> 11) | (g << 21)) ^ ((g >>> 25) | (g << 7)); |
||||
cd = c & d; |
||||
maj = cd ^ (c & a) ^ da; |
||||
ch = (g & h) ^ (~g & e); |
||||
t1 = f + s1 + ch + K[j + 2] + blocks[j + 2]; |
||||
t2 = s0 + maj; |
||||
f = b + t1 << 0; |
||||
b = t1 + t2 << 0; |
||||
s0 = ((b >>> 2) | (b << 30)) ^ ((b >>> 13) | (b << 19)) ^ ((b >>> 22) | (b << 10)); |
||||
s1 = ((f >>> 6) | (f << 26)) ^ ((f >>> 11) | (f << 21)) ^ ((f >>> 25) | (f << 7)); |
||||
bc = b & c; |
||||
maj = bc ^ (b & d) ^ cd; |
||||
ch = (f & g) ^ (~f & h); |
||||
t1 = e + s1 + ch + K[j + 3] + blocks[j + 3]; |
||||
t2 = s0 + maj; |
||||
e = a + t1 << 0; |
||||
a = t1 + t2 << 0; |
||||
} |
||||
|
||||
this.h0 = this.h0 + a << 0; |
||||
this.h1 = this.h1 + b << 0; |
||||
this.h2 = this.h2 + c << 0; |
||||
this.h3 = this.h3 + d << 0; |
||||
this.h4 = this.h4 + e << 0; |
||||
this.h5 = this.h5 + f << 0; |
||||
this.h6 = this.h6 + g << 0; |
||||
this.h7 = this.h7 + h << 0; |
||||
}; |
||||
|
||||
Sha256.prototype.hex = function () { |
||||
this.finalize(); |
||||
|
||||
var h0 = this.h0, h1 = this.h1, h2 = this.h2, h3 = this.h3, h4 = this.h4, h5 = this.h5, |
||||
h6 = this.h6, h7 = this.h7; |
||||
|
||||
var hex = HEX_CHARS[(h0 >> 28) & 0x0F] + HEX_CHARS[(h0 >> 24) & 0x0F] + |
||||
HEX_CHARS[(h0 >> 20) & 0x0F] + HEX_CHARS[(h0 >> 16) & 0x0F] + |
||||
HEX_CHARS[(h0 >> 12) & 0x0F] + HEX_CHARS[(h0 >> 8) & 0x0F] + |
||||
HEX_CHARS[(h0 >> 4) & 0x0F] + HEX_CHARS[h0 & 0x0F] + |
||||
HEX_CHARS[(h1 >> 28) & 0x0F] + HEX_CHARS[(h1 >> 24) & 0x0F] + |
||||
HEX_CHARS[(h1 >> 20) & 0x0F] + HEX_CHARS[(h1 >> 16) & 0x0F] + |
||||
HEX_CHARS[(h1 >> 12) & 0x0F] + HEX_CHARS[(h1 >> 8) & 0x0F] + |
||||
HEX_CHARS[(h1 >> 4) & 0x0F] + HEX_CHARS[h1 & 0x0F] + |
||||
HEX_CHARS[(h2 >> 28) & 0x0F] + HEX_CHARS[(h2 >> 24) & 0x0F] + |
||||
HEX_CHARS[(h2 >> 20) & 0x0F] + HEX_CHARS[(h2 >> 16) & 0x0F] + |
||||
HEX_CHARS[(h2 >> 12) & 0x0F] + HEX_CHARS[(h2 >> 8) & 0x0F] + |
||||
HEX_CHARS[(h2 >> 4) & 0x0F] + HEX_CHARS[h2 & 0x0F] + |
||||
HEX_CHARS[(h3 >> 28) & 0x0F] + HEX_CHARS[(h3 >> 24) & 0x0F] + |
||||
HEX_CHARS[(h3 >> 20) & 0x0F] + HEX_CHARS[(h3 >> 16) & 0x0F] + |
||||
HEX_CHARS[(h3 >> 12) & 0x0F] + HEX_CHARS[(h3 >> 8) & 0x0F] + |
||||
HEX_CHARS[(h3 >> 4) & 0x0F] + HEX_CHARS[h3 & 0x0F] + |
||||
HEX_CHARS[(h4 >> 28) & 0x0F] + HEX_CHARS[(h4 >> 24) & 0x0F] + |
||||
HEX_CHARS[(h4 >> 20) & 0x0F] + HEX_CHARS[(h4 >> 16) & 0x0F] + |
||||
HEX_CHARS[(h4 >> 12) & 0x0F] + HEX_CHARS[(h4 >> 8) & 0x0F] + |
||||
HEX_CHARS[(h4 >> 4) & 0x0F] + HEX_CHARS[h4 & 0x0F] + |
||||
HEX_CHARS[(h5 >> 28) & 0x0F] + HEX_CHARS[(h5 >> 24) & 0x0F] + |
||||
HEX_CHARS[(h5 >> 20) & 0x0F] + HEX_CHARS[(h5 >> 16) & 0x0F] + |
||||
HEX_CHARS[(h5 >> 12) & 0x0F] + HEX_CHARS[(h5 >> 8) & 0x0F] + |
||||
HEX_CHARS[(h5 >> 4) & 0x0F] + HEX_CHARS[h5 & 0x0F] + |
||||
HEX_CHARS[(h6 >> 28) & 0x0F] + HEX_CHARS[(h6 >> 24) & 0x0F] + |
||||
HEX_CHARS[(h6 >> 20) & 0x0F] + HEX_CHARS[(h6 >> 16) & 0x0F] + |
||||
HEX_CHARS[(h6 >> 12) & 0x0F] + HEX_CHARS[(h6 >> 8) & 0x0F] + |
||||
HEX_CHARS[(h6 >> 4) & 0x0F] + HEX_CHARS[h6 & 0x0F]; |
||||
if (!this.is224) { |
||||
hex += HEX_CHARS[(h7 >> 28) & 0x0F] + HEX_CHARS[(h7 >> 24) & 0x0F] + |
||||
HEX_CHARS[(h7 >> 20) & 0x0F] + HEX_CHARS[(h7 >> 16) & 0x0F] + |
||||
HEX_CHARS[(h7 >> 12) & 0x0F] + HEX_CHARS[(h7 >> 8) & 0x0F] + |
||||
HEX_CHARS[(h7 >> 4) & 0x0F] + HEX_CHARS[h7 & 0x0F]; |
||||
} |
||||
return hex; |
||||
}; |
||||
|
||||
Sha256.prototype.toString = Sha256.prototype.hex; |
||||
|
||||
Sha256.prototype.digest = function () { |
||||
this.finalize(); |
||||
|
||||
var h0 = this.h0, h1 = this.h1, h2 = this.h2, h3 = this.h3, h4 = this.h4, h5 = this.h5, |
||||
h6 = this.h6, h7 = this.h7; |
||||
|
||||
var arr = [ |
||||
(h0 >> 24) & 0xFF, (h0 >> 16) & 0xFF, (h0 >> 8) & 0xFF, h0 & 0xFF, |
||||
(h1 >> 24) & 0xFF, (h1 >> 16) & 0xFF, (h1 >> 8) & 0xFF, h1 & 0xFF, |
||||
(h2 >> 24) & 0xFF, (h2 >> 16) & 0xFF, (h2 >> 8) & 0xFF, h2 & 0xFF, |
||||
(h3 >> 24) & 0xFF, (h3 >> 16) & 0xFF, (h3 >> 8) & 0xFF, h3 & 0xFF, |
||||
(h4 >> 24) & 0xFF, (h4 >> 16) & 0xFF, (h4 >> 8) & 0xFF, h4 & 0xFF, |
||||
(h5 >> 24) & 0xFF, (h5 >> 16) & 0xFF, (h5 >> 8) & 0xFF, h5 & 0xFF, |
||||
(h6 >> 24) & 0xFF, (h6 >> 16) & 0xFF, (h6 >> 8) & 0xFF, h6 & 0xFF |
||||
]; |
||||
if (!this.is224) { |
||||
arr.push((h7 >> 24) & 0xFF, (h7 >> 16) & 0xFF, (h7 >> 8) & 0xFF, h7 & 0xFF); |
||||
} |
||||
return arr; |
||||
}; |
||||
|
||||
Sha256.prototype.array = Sha256.prototype.digest; |
||||
|
||||
Sha256.prototype.arrayBuffer = function () { |
||||
this.finalize(); |
||||
|
||||
var buffer = new ArrayBuffer(this.is224 ? 28 : 32); |
||||
var dataView = new DataView(buffer); |
||||
dataView.setUint32(0, this.h0); |
||||
dataView.setUint32(4, this.h1); |
||||
dataView.setUint32(8, this.h2); |
||||
dataView.setUint32(12, this.h3); |
||||
dataView.setUint32(16, this.h4); |
||||
dataView.setUint32(20, this.h5); |
||||
dataView.setUint32(24, this.h6); |
||||
if (!this.is224) { |
||||
dataView.setUint32(28, this.h7); |
||||
} |
||||
return buffer; |
||||
}; |
||||
|
||||
function HmacSha256(key, is224, sharedMemory) { |
||||
var i, type = typeof key; |
||||
if (type === 'string') { |
||||
var bytes = [], length = key.length, index = 0, code; |
||||
for (i = 0; i < length; ++i) { |
||||
code = key.charCodeAt(i); |
||||
if (code < 0x80) { |
||||
bytes[index++] = code; |
||||
} else if (code < 0x800) { |
||||
bytes[index++] = (0xc0 | (code >> 6)); |
||||
bytes[index++] = (0x80 | (code & 0x3f)); |
||||
} else if (code < 0xd800 || code >= 0xe000) { |
||||
bytes[index++] = (0xe0 | (code >> 12)); |
||||
bytes[index++] = (0x80 | ((code >> 6) & 0x3f)); |
||||
bytes[index++] = (0x80 | (code & 0x3f)); |
||||
} else { |
||||
code = 0x10000 + (((code & 0x3ff) << 10) | (key.charCodeAt(++i) & 0x3ff)); |
||||
bytes[index++] = (0xf0 | (code >> 18)); |
||||
bytes[index++] = (0x80 | ((code >> 12) & 0x3f)); |
||||
bytes[index++] = (0x80 | ((code >> 6) & 0x3f)); |
||||
bytes[index++] = (0x80 | (code & 0x3f)); |
||||
} |
||||
} |
||||
key = bytes; |
||||
} else { |
||||
if (type === 'object') { |
||||
if (key === null) { |
||||
throw new Error(ERROR); |
||||
} else if (ARRAY_BUFFER && key.constructor === ArrayBuffer) { |
||||
key = new Uint8Array(key); |
||||
} else if (!Array.isArray(key)) { |
||||
if (!ARRAY_BUFFER || !ArrayBuffer.isView(key)) { |
||||
throw new Error(ERROR); |
||||
} |
||||
} |
||||
} else { |
||||
throw new Error(ERROR); |
||||
} |
||||
} |
||||
|
||||
if (key.length > 64) { |
||||
key = (new Sha256(is224, true)).update(key).array(); |
||||
} |
||||
|
||||
var oKeyPad = [], iKeyPad = []; |
||||
for (i = 0; i < 64; ++i) { |
||||
var b = key[i] || 0; |
||||
oKeyPad[i] = 0x5c ^ b; |
||||
iKeyPad[i] = 0x36 ^ b; |
||||
} |
||||
|
||||
Sha256.call(this, is224, sharedMemory); |
||||
|
||||
this.update(iKeyPad); |
||||
this.oKeyPad = oKeyPad; |
||||
this.inner = true; |
||||
this.sharedMemory = sharedMemory; |
||||
} |
||||
HmacSha256.prototype = new Sha256(); |
||||
|
||||
HmacSha256.prototype.finalize = function () { |
||||
Sha256.prototype.finalize.call(this); |
||||
if (this.inner) { |
||||
this.inner = false; |
||||
var innerHash = this.array(); |
||||
Sha256.call(this, this.is224, this.sharedMemory); |
||||
this.update(this.oKeyPad); |
||||
this.update(innerHash); |
||||
Sha256.prototype.finalize.call(this); |
||||
} |
||||
}; |
||||
|
||||
var exports = createMethod(); |
||||
exports.sha256 = exports; |
||||
exports.sha224 = createMethod(true); |
||||
exports.sha256.hmac = createHmacMethod(); |
||||
exports.sha224.hmac = createHmacMethod(true); |
||||
|
||||
if (COMMON_JS) { |
||||
module.exports = exports; |
||||
} else { |
||||
root.sha256 = exports.sha256; |
||||
root.sha224 = exports.sha224; |
||||
if (AMD) { |
||||
define(function () { |
||||
return exports; |
||||
}); |
||||
} |
||||
} |
||||
})(); |
@ -0,0 +1,87 @@
|
||||
/* |
||||
dela - web TODO list |
||||
Copyright (C) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte) |
||||
|
||||
This program is free software: you can redistribute it and/or modify |
||||
it under the terms of the GNU Affero General Public License as published by |
||||
the Free Software Foundation, either version 3 of the License, or |
||||
(at your option) any later version. |
||||
|
||||
This program is distributed in the hope that it will be useful, |
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
GNU Affero General Public License for more details. |
||||
|
||||
You should have received a copy of the GNU Affero General Public License |
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/ |
||||
|
||||
package conf |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"io" |
||||
"os" |
||||
) |
||||
|
||||
type Conf struct { |
||||
Port uint16 `json:"port"` |
||||
CertFilePath string `json:"cert_file_path"` |
||||
KeyFilePath string `json:"key_file_path"` |
||||
BaseContentDir string `json:"base_content_dir"` |
||||
ProdDBName string `json:"production_db_name"` |
||||
} |
||||
|
||||
// Creates a default server configuration
|
||||
func Default() Conf { |
||||
return Conf{ |
||||
Port: 8080, |
||||
CertFilePath: "", |
||||
KeyFilePath: "", |
||||
BaseContentDir: ".", |
||||
ProdDBName: "dela.db", |
||||
} |
||||
} |
||||
|
||||
// Tries to retrieve configuration from given json file
|
||||
func FromFile(path string) (Conf, error) { |
||||
configFile, err := os.Open(path) |
||||
if err != nil { |
||||
return Default(), err |
||||
} |
||||
defer configFile.Close() |
||||
|
||||
confBytes, err := io.ReadAll(configFile) |
||||
if err != nil { |
||||
return Default(), err |
||||
} |
||||
|
||||
var config Conf |
||||
err = json.Unmarshal(confBytes, &config) |
||||
if err != nil { |
||||
return Default(), err |
||||
} |
||||
|
||||
return config, nil |
||||
} |
||||
|
||||
// Create empty configuration file
|
||||
func Create(path string, conf Conf) (Conf, error) { |
||||
configFile, err := os.Create(path) |
||||
if err != nil { |
||||
return Default(), err |
||||
} |
||||
defer configFile.Close() |
||||
|
||||
configJsonBytes, err := json.MarshalIndent(conf, "", " ") |
||||
if err != nil { |
||||
return conf, err |
||||
} |
||||
|
||||
_, err = configFile.Write(configJsonBytes) |
||||
if err != nil { |
||||
return conf, nil |
||||
} |
||||
|
||||
return conf, nil |
||||
} |
@ -0,0 +1,113 @@
|
||||
/* |
||||
dela - web TODO list |
||||
Copyright (C) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte) |
||||
|
||||
This program is free software: you can redistribute it and/or modify |
||||
it under the terms of the GNU Affero General Public License as published by |
||||
the Free Software Foundation, either version 3 of the License, or |
||||
(at your option) any later version. |
||||
|
||||
This program is distributed in the hope that it will be useful, |
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
GNU Affero General Public License for more details. |
||||
|
||||
You should have received a copy of the GNU Affero General Public License |
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/ |
||||
|
||||
package db |
||||
|
||||
import ( |
||||
"database/sql" |
||||
"os" |
||||
|
||||
_ "modernc.org/sqlite" |
||||
) |
||||
|
||||
// Database wrapper
|
||||
type DB struct { |
||||
*sql.DB |
||||
} |
||||
|
||||
func setUpTables(db *DB) error { |
||||
// Users
|
||||
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS users( |
||||
login TEXT PRIMARY KEY UNIQUE, |
||||
email TEXT NOT NULL UNIQUE, |
||||
password TEXT NOT NULL, |
||||
time_created_unix INTEGER)`, |
||||
) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
// Todo groups
|
||||
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS todo_groups( |
||||
id INTEGER PRIMARY KEY AUTOINCREMENT UNIQUE, |
||||
name TEXT, |
||||
time_created_unix INTEGER, |
||||
owner_login TEXT NOT NULL, |
||||
FOREIGN KEY(owner_login) REFERENCES users(login))`, |
||||
) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
// Todos
|
||||
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS todos( |
||||
id INTEGER PRIMARY KEY AUTOINCREMENT UNIQUE, |
||||
group_id INTEGER NOT NULL, |
||||
text TEXT NOT NULL, |
||||
time_created_unix INTEGER, |
||||
due_unix INTEGER, |
||||
owner_login TEXT NOT NULL, |
||||
is_done INTEGER, |
||||
completion_time_unix INTEGER, |
||||
FOREIGN KEY(group_id) REFERENCES todo_groups(id), |
||||
FOREIGN KEY(owner_login) REFERENCES users(login))`, |
||||
) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
// Open database
|
||||
func FromFile(path string) (*DB, error) { |
||||
driver, err := sql.Open("sqlite", path) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
dbase := &DB{driver} |
||||
|
||||
err = setUpTables(dbase) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return dbase, nil |
||||
} |
||||
|
||||
// Create database file
|
||||
func Create(path string) (*DB, error) { |
||||
dbFile, err := os.Create(path) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
dbFile.Close() |
||||
|
||||
driver, err := sql.Open("sqlite", path) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
dbase := &DB{driver} |
||||
|
||||
err = setUpTables(dbase) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return dbase, nil |
||||
} |
@ -0,0 +1,97 @@
|
||||
/* |
||||
dela - web TODO list |
||||
Copyright (C) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte) |
||||
|
||||
This program is free software: you can redistribute it and/or modify |
||||
it under the terms of the GNU Affero General Public License as published by |
||||
the Free Software Foundation, either version 3 of the License, or |
||||
(at your option) any later version. |
||||
|
||||
This program is distributed in the hope that it will be useful, |
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
GNU Affero General Public License for more details. |
||||
|
||||
You should have received a copy of the GNU Affero General Public License |
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/ |
||||
|
||||
package db |
||||
|
||||
import ( |
||||
"os" |
||||
"path/filepath" |
||||
"testing" |
||||
) |
||||
|
||||
func TestApi(t *testing.T) { |
||||
db, err := Create(filepath.Join(os.TempDir(), "dela_test.db")) |
||||
if err != nil { |
||||
t.Fatalf("failed to create database: %s", err) |
||||
} |
||||
|
||||
// User
|
||||
user := User{ |
||||
Login: "user1", |
||||
Password: "ruohguoeruoger", |
||||
TimeCreatedUnix: 12421467, |
||||
} |
||||
|
||||
err = db.CreateUser(user) |
||||
if err != nil { |
||||
t.Fatalf("failed to create user: %s", err) |
||||
} |
||||
|
||||
dbUser, err := db.GetUser(user.Login) |
||||
if err != nil { |
||||
t.Fatalf("failed to retrieve created user: %s", err) |
||||
} |
||||
|
||||
if dbUser.Password != user.Password { |
||||
t.Fatalf("user passwords don't match") |
||||
} |
||||
|
||||
// Todos
|
||||
group := TodoGroup{ |
||||
Name: "group1", |
||||
TimeCreatedUnix: 13524534, |
||||
OwnerLogin: user.Login, |
||||
} |
||||
|
||||
err = db.CreateTodoGroup(group) |
||||
if err != nil { |
||||
t.Fatalf("failed to create todo group: %s", err) |
||||
} |
||||
|
||||
err = db.UpdateTodoGroup(1, TodoGroup{Name: "updated_name"}) |
||||
if err != nil { |
||||
t.Fatalf("failed to update todo group: %s", err) |
||||
} |
||||
|
||||
dbGroup, err := db.GetTodoGroup(1) |
||||
if err != nil { |
||||
t.Fatalf("failed to get created TODO group: %s", err) |
||||
} |
||||
|
||||
if dbGroup.Name == group.Name { |
||||
t.Fatalf("name match changed value for a TODO group") |
||||
} |
||||
|
||||
todo := Todo{ |
||||
GroupID: dbGroup.ID, |
||||
Text: "Do the dishes", |
||||
TimeCreatedUnix: dbGroup.TimeCreatedUnix, |
||||
DueUnix: 0, |
||||
OwnerLogin: user.Login, |
||||
} |
||||
err = db.CreateTodo(todo) |
||||
if err != nil { |
||||
t.Fatalf("couldn't create a new TODO: %s", err) |
||||
} |
||||
|
||||
// Now deletion
|
||||
err = db.DeleteUserClean(user.Login) |
||||
if err != nil { |
||||
t.Fatalf("couldn't cleanly delete user with all TODOs: %s", err) |
||||
} |
||||
} |
@ -0,0 +1,113 @@
|
||||
package db |
||||
|
||||
import "database/sql" |
||||
|
||||
// Todo group structure
|
||||
type TodoGroup struct { |
||||
ID uint64 `json:"id"` |
||||
Name string `json:"name"` |
||||
TimeCreatedUnix uint64 `json:"timeCreatedUnix"` |
||||
OwnerLogin string `json:"ownerLogin"` |
||||
} |
||||
|
||||
// Creates a new TODO group in the database
|
||||
func (db *DB) CreateTodoGroup(group TodoGroup) error { |
||||
_, err := db.Exec( |
||||
"INSERT INTO todo_groups(name, time_created_unix, owner_username) VALUES(?, ?, ?)", |
||||
group.Name, |
||||
group.TimeCreatedUnix, |
||||
group.OwnerLogin, |
||||
) |
||||
|
||||
return err |
||||
} |
||||
|
||||
func scanTodoGroup(rows *sql.Rows) (*TodoGroup, error) { |
||||
var newTodoGroup TodoGroup |
||||
err := rows.Scan( |
||||
&newTodoGroup.ID, |
||||
&newTodoGroup.Name, |
||||
&newTodoGroup.TimeCreatedUnix, |
||||
&newTodoGroup.OwnerLogin, |
||||
) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return &newTodoGroup, nil |
||||
} |
||||
|
||||
// Retrieves a TODO group with provided ID from the database
|
||||
func (db *DB) GetTodoGroup(id uint64) (*TodoGroup, error) { |
||||
rows, err := db.Query( |
||||
"SELECT * FROM todo_groups WHERE id=?", |
||||
id, |
||||
) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
defer rows.Close() |
||||
|
||||
rows.Next() |
||||
todoGroup, err := scanTodoGroup(rows) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return todoGroup, nil |
||||
} |
||||
|
||||
// Retrieves information on ALL TODO groups
|
||||
func (db *DB) GetTodoGroups() ([]*TodoGroup, error) { |
||||
var groups []*TodoGroup |
||||
|
||||
rows, err := db.Query("SELECT * FROM todo_groups") |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
defer rows.Close() |
||||
|
||||
for rows.Next() { |
||||
todoGroup, err := scanTodoGroup(rows) |
||||
if err != nil { |
||||
return groups, err |
||||
} |
||||
groups = append(groups, todoGroup) |
||||
} |
||||
|
||||
return groups, nil |
||||
} |
||||
|
||||
// Deletes information about a TODO group of given ID from the database
|
||||
func (db *DB) DeleteTodoGroup(id uint64) error { |
||||
_, err := db.Exec( |
||||
"DELETE FROM todo_groups WHERE id=?", |
||||
id, |
||||
) |
||||
|
||||
return err |
||||
} |
||||
|
||||
// Updates TODO group's name
|
||||
func (db *DB) UpdateTodoGroup(groupID uint64, updatedGroup TodoGroup) error { |
||||
_, err := db.Exec( |
||||
"UPDATE todo_groups SET name=? WHERE id=?", |
||||
updatedGroup.Name, |
||||
groupID, |
||||
) |
||||
|
||||
return err |
||||
} |
||||
|
||||
func (db *DB) DoesUserOwnGroup(groupId uint64, login string) bool { |
||||
group, err := db.GetTodoGroup(groupId) |
||||
if err != nil { |
||||
return false |
||||
} |
||||
|
||||
if group.OwnerLogin != login { |
||||
return false |
||||
} |
||||
|
||||
return true |
||||
} |
@ -0,0 +1,216 @@
|
||||
/* |
||||
dela - web TODO list |
||||
Copyright (C) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte) |
||||
|
||||
This program is free software: you can redistribute it and/or modify |
||||
it under the terms of the GNU Affero General Public License as published by |
||||
the Free Software Foundation, either version 3 of the License, or |
||||
(at your option) any later version. |
||||
|
||||
This program is distributed in the hope that it will be useful, |
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
GNU Affero General Public License for more details. |
||||
|
||||
You should have received a copy of the GNU Affero General Public License |
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/ |
||||
|
||||
package db |
||||
|
||||
import "database/sql" |
||||
|
||||
// Todo structure
|
||||
type Todo struct { |
||||
ID uint64 `json:"id"` |
||||
GroupID uint64 `json:"groupId"` |
||||
Text string `json:"text"` |
||||
TimeCreatedUnix uint64 `json:"timeCreatedUnix"` |
||||
DueUnix uint64 `json:"dueUnix"` |
||||
OwnerLogin string `json:"ownerLogin"` |
||||
IsDone bool `json:"isDone"` |
||||
CompletionTimeUnix uint64 `json:"completionTimeUnix"` |
||||
} |
||||
|
||||
func scanTodo(rows *sql.Rows) (*Todo, error) { |
||||
var newTodo Todo |
||||
err := rows.Scan( |
||||
&newTodo.ID, |
||||
&newTodo.GroupID, |
||||
&newTodo.Text, |
||||
&newTodo.TimeCreatedUnix, |
||||
&newTodo.DueUnix, |
||||
&newTodo.OwnerLogin, |
||||
&newTodo.IsDone, |
||||
&newTodo.CompletionTimeUnix, |
||||
) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return &newTodo, nil |
||||
} |
||||
|
||||
// Retrieves a TODO with given Id from the database
|
||||
func (db *DB) GetTodo(id uint64) (*Todo, error) { |
||||
rows, err := db.Query( |
||||
"SELECT * FROM todos WHERE id=?", |
||||
id, |
||||
) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
defer rows.Close() |
||||
|
||||
rows.Next() |
||||
todo, err := scanTodo(rows) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return todo, nil |
||||
} |
||||
|
||||
// Retrieves information on ALL TODOs
|
||||
func (db *DB) GetTodos() ([]*Todo, error) { |
||||
var todos []*Todo |
||||
|
||||
rows, err := db.Query("SELECT * FROM todos") |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
defer rows.Close() |
||||
|
||||
for rows.Next() { |
||||
todo, err := scanTodo(rows) |
||||
if err != nil { |
||||
return todos, err |
||||
} |
||||
todos = append(todos, todo) |
||||
} |
||||
|
||||
return todos, nil |
||||
} |
||||
|
||||
// Creates a new TODO in the database
|
||||
func (db *DB) CreateTodo(todo Todo) error { |
||||
_, err := db.Exec( |
||||
"INSERT INTO todos(group_id, text, time_created_unix, due_unix, owner_username, is_done, completion_time_unix) VALUES(?, ?, ?, ?, ?, ?, ?)", |
||||
todo.GroupID, |
||||
todo.Text, |
||||
todo.TimeCreatedUnix, |
||||
todo.DueUnix, |
||||
todo.OwnerLogin, |
||||
todo.IsDone, |
||||
todo.CompletionTimeUnix, |
||||
) |
||||
|
||||
return err |
||||
} |
||||
|
||||
// Deletes information about a TODO of certain ID from the database
|
||||
func (db *DB) DeleteTodo(id uint64) error { |
||||
_, err := db.Exec( |
||||
"DELETE FROM todos WHERE id=?", |
||||
id, |
||||
) |
||||
|
||||
return err |
||||
} |
||||
|
||||
// Updates TODO's due date, text, done state, completion time and group id
|
||||
func (db *DB) UpdateTodo(todoID uint64, updatedTodo Todo) error { |
||||
_, err := db.Exec( |
||||
"UPDATE todos SET group_id=?, due_unix=?, text=?, is_done=?, completion_time_unix=? WHERE id=?", |
||||
updatedTodo.GroupID, |
||||
updatedTodo.DueUnix, |
||||
updatedTodo.Text, |
||||
updatedTodo.IsDone, |
||||
updatedTodo.CompletionTimeUnix, |
||||
todoID, |
||||
) |
||||
|
||||
return err |
||||
} |
||||
|
||||
// Searches and retrieves TODO groups created by the user
|
||||
func (db *DB) GetAllUserTodoGroups(username string) ([]*TodoGroup, error) { |
||||
var todoGroups []*TodoGroup |
||||
|
||||
rows, err := db.Query( |
||||
"SELECT * FROM todo_groups WHERE owner_username=?", |
||||
username, |
||||
) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
defer rows.Close() |
||||
|
||||
for rows.Next() { |
||||
group, err := scanTodoGroup(rows) |
||||
if err != nil { |
||||
continue |
||||
} |
||||
todoGroups = append(todoGroups, group) |
||||
} |
||||
|
||||
return todoGroups, nil |
||||
} |
||||
|
||||
// Searches and retrieves TODOs created by the user
|
||||
func (db *DB) GetAllUserTodos(username string) ([]*Todo, error) { |
||||
var todos []*Todo |
||||
|
||||
rows, err := db.Query( |
||||
"SELECT * FROM todos WHERE owner_username=?", |
||||
username, |
||||
) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
defer rows.Close() |
||||
|
||||
for rows.Next() { |
||||
todo, err := scanTodo(rows) |
||||
if err != nil { |
||||
continue |
||||
} |
||||
|
||||
todos = append(todos, todo) |
||||
} |
||||
|
||||
return todos, nil |
||||
} |
||||
|
||||
// Deletes all information regarding TODOs of specified user
|
||||
func (db *DB) DeleteAllUserTodos(username string) error { |
||||
_, err := db.Exec( |
||||
"DELETE FROM todos WHERE owner_username=?", |
||||
username, |
||||
) |
||||
|
||||
return err |
||||
} |
||||
|
||||
// Deletes all information regarding TODO groups of specified user
|
||||
func (db *DB) DeleteAllUserTodoGroups(username string) error { |
||||
_, err := db.Exec( |
||||
"DELETE FROM todo_groups WHERE owner_username=?", |
||||
username, |
||||
) |
||||
|
||||
return err |
||||
} |
||||
|
||||
func (db *DB) DoesUserOwnTodo(todoId uint64, login string) bool { |
||||
todo, err := db.GetTodo(todoId) |
||||
if err != nil { |
||||
return false |
||||
} |
||||
|
||||
if todo.OwnerLogin != login { |
||||
return false |
||||
} |
||||
|
||||
return true |
||||
} |
@ -0,0 +1,110 @@
|
||||
/* |
||||
dela - web TODO list |
||||
Copyright (C) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte) |
||||
|
||||
This program is free software: you can redistribute it and/or modify |
||||
it under the terms of the GNU Affero General Public License as published by |
||||
the Free Software Foundation, either version 3 of the License, or |
||||
(at your option) any later version. |
||||
|
||||
This program is distributed in the hope that it will be useful, |
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
GNU Affero General Public License for more details. |
||||
|
||||
You should have received a copy of the GNU Affero General Public License |
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/ |
||||
|
||||
package db |
||||
|
||||
import "database/sql" |
||||
|
||||
// User structure
|
||||
type User struct { |
||||
Login string `json:"login"` |
||||
Email string `json:"email"` |
||||
Password string `json:"password"` |
||||
TimeCreatedUnix uint64 `json:"timeCreatedUnix"` |
||||
} |
||||
|
||||
func scanUser(rows *sql.Rows) (*User, error) { |
||||
rows.Next() |
||||
var user User |
||||
err := rows.Scan(&user.Login, &user.Email, &user.Password, &user.TimeCreatedUnix) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return &user, nil |
||||
} |
||||
|
||||
// Searches for user with login and returns it
|
||||
func (db *DB) GetUser(login string) (*User, error) { |
||||
rows, err := db.Query("SELECT * FROM users WHERE login=?", login) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
defer rows.Close() |
||||
|
||||
user, err := scanUser(rows) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return user, nil |
||||
} |
||||
|
||||
// Creates a new user in the database
|
||||
func (db *DB) CreateUser(newUser User) error { |
||||
_, err := db.Exec( |
||||
"INSERT INTO users(login, email, password, time_created_unix) VALUES(?, ?, ?, ?)", |
||||
newUser.Login, |
||||
newUser.Email, |
||||
newUser.Password, |
||||
newUser.TimeCreatedUnix, |
||||
) |
||||
|
||||
return err |
||||
} |
||||
|
||||
// Deletes user with given login
|
||||
func (db *DB) DeleteUser(login string) error { |
||||
_, err := db.Exec( |
||||
"DELETE FROM users WHERE login=?", |
||||
login, |
||||
) |
||||
|
||||
return err |
||||
} |
||||
|
||||
func (db *DB) UserUpdate(newUser User) error { |
||||
_, err := db.Exec( |
||||
"UPDATE users SET email=? password=? WHERE login=?", |
||||
newUser.Email, |
||||
newUser.Password, |
||||
newUser.Login, |
||||
) |
||||
|
||||
return err |
||||
} |
||||
|
||||
// Deletes a user and all his TODOs (with groups) as well
|
||||
func (db *DB) DeleteUserClean(login string) error { |
||||
err := db.DeleteAllUserTodoGroups(login) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
err = db.DeleteAllUserTodos(login) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
err = db.DeleteUser(login) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
return nil |
||||
} |
@ -0,0 +1,43 @@
|
||||
/* |
||||
dela - web TODO list |
||||
Copyright (C) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte) |
||||
|
||||
This program is free software: you can redistribute it and/or modify |
||||
it under the terms of the GNU Affero General Public License as published by |
||||
the Free Software Foundation, either version 3 of the License, or |
||||
(at your option) any later version. |
||||
|
||||
This program is distributed in the hope that it will be useful, |
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
GNU Affero General Public License for more details. |
||||
|
||||
You should have received a copy of the GNU Affero General Public License |
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/ |
||||
|
||||
package encryption |
||||
|
||||
import ( |
||||
"crypto/sha256" |
||||
"encoding/base64" |
||||
"fmt" |
||||
) |
||||
|
||||
// Encodes given string via Base64
|
||||
func EncodeString(str string) string { |
||||
return base64.StdEncoding.EncodeToString([]byte(str)) |
||||
} |
||||
|
||||
// Decodes given string via Base64
|
||||
func DecodeString(encodedStr string) string { |
||||
decodedBytes, _ := base64.StdEncoding.DecodeString(encodedStr) |
||||
return string(decodedBytes) |
||||
} |
||||
|
||||
// Returns HEX string of SHA256'd data
|
||||
func SHA256Hex(data []byte) string { |
||||
hash := sha256.New() |
||||
hash.Write(data) |
||||
return fmt.Sprintf("%x", hash.Sum(nil)) |
||||
} |
@ -0,0 +1,26 @@
|
||||
module Unbewohnte/dela |
||||
|
||||
go 1.20 |
||||
|
||||
require modernc.org/sqlite v1.22.0 |
||||
|
||||
require ( |
||||
github.com/dustin/go-humanize v1.0.1 // indirect |
||||
github.com/google/uuid v1.3.0 // indirect |
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect |
||||
github.com/mattn/go-isatty v0.0.16 // indirect |
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
||||
golang.org/x/mod v0.3.0 // indirect |
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect |
||||
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 // indirect |
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect |
||||
lukechampine.com/uint128 v1.2.0 // indirect |
||||
modernc.org/cc/v3 v3.40.0 // indirect |
||||
modernc.org/ccgo/v3 v3.16.13 // indirect |
||||
modernc.org/libc v1.22.4 // indirect |
||||
modernc.org/mathutil v1.5.0 // indirect |
||||
modernc.org/memory v1.5.0 // indirect |
||||
modernc.org/opt v0.1.3 // indirect |
||||
modernc.org/strutil v1.1.3 // indirect |
||||
modernc.org/token v1.0.1 // indirect |
||||
) |
@ -0,0 +1,65 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= |
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= |
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= |
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= |
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= |
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= |
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= |
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= |
||||
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= |
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= |
||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= |
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= |
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= |
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= |
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= |
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= |
||||
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4= |
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= |
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= |
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= |
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= |
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= |
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= |
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= |
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU= |
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= |
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= |
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= |
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= |
||||
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 h1:M8tBwCtWD/cZV9DZpFYRUgaymAYAr+aIUTWzDaM3uPs= |
||||
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= |
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= |
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= |
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= |
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= |
||||
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= |
||||
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= |
||||
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= |
||||
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= |
||||
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw= |
||||
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= |
||||
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= |
||||
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= |
||||
modernc.org/libc v1.22.4 h1:wymSbZb0AlrjdAVX3cjreCHTPCpPARbQXNz6BHPzdwQ= |
||||
modernc.org/libc v1.22.4/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= |
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= |
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= |
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= |
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= |
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= |
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= |
||||
modernc.org/sqlite v1.22.0 h1:Uo+wEWePCspy4SAu0w2VbzUHEftOs7yoaWX/cYjsq84= |
||||
modernc.org/sqlite v1.22.0/go.mod h1:cxbLkB5WS32DnQqeH4h4o1B0eMr8W/y8/RGuxQ3JsC0= |
||||
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= |
||||
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= |
||||
modernc.org/tcl v1.15.1 h1:mOQwiEK4p7HruMZcwKTZPw/aqtGM4aY00uzWhlKKYws= |
||||
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg= |
||||
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= |
||||
modernc.org/z v1.7.0 h1:xkDw/KepgEjeizO2sNco+hqYkU12taxQFqPEmgm1GWE= |
@ -0,0 +1,60 @@
|
||||
/* |
||||
dela - web TODO list |
||||
Copyright (C) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte) |
||||
|
||||
This program is free software: you can redistribute it and/or modify |
||||
it under the terms of the GNU Affero General Public License as published by |
||||
the Free Software Foundation, either version 3 of the License, or |
||||
(at your option) any later version. |
||||
|
||||
This program is distributed in the hope that it will be useful, |
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
GNU Affero General Public License for more details. |
||||
|
||||
You should have received a copy of the GNU Affero General Public License |
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/ |
||||
|
||||
package logger |
||||
|
||||
import ( |
||||
"io" |
||||
"log" |
||||
"os" |
||||
) |
||||
|
||||
// 3 basic loggers in global space
|
||||
var ( |
||||
// neutral information logger
|
||||
infoLog *log.Logger |
||||
// warning-level information logger
|
||||
warningLog *log.Logger |
||||
// errors information logger
|
||||
errorLog *log.Logger |
||||
) |
||||
|
||||
func init() { |
||||
infoLog = log.New(os.Stdout, "[INFO] ", log.Ldate|log.Ltime) |
||||
warningLog = log.New(os.Stdout, "[WARNING] ", log.Ldate|log.Ltime) |
||||
errorLog = log.New(os.Stdout, "[ERROR] ", log.Ldate|log.Ltime) |
||||
} |
||||
|
||||
// Set up loggers to write to the given writer
|
||||
func SetOutput(writer io.Writer) { |
||||
if writer == nil { |
||||
writer = io.Discard |
||||
} |
||||
infoLog.SetOutput(writer) |
||||
warningLog.SetOutput(writer) |
||||
errorLog.SetOutput(writer) |
||||
} |
||||
func Info(format string, a ...interface{}) { |
||||
infoLog.Printf(format, a...) |
||||
} |
||||
func Warning(format string, a ...interface{}) { |
||||
warningLog.Printf(format, a...) |
||||
} |
||||
func Error(format string, a ...interface{}) { |
||||
errorLog.Printf(format, a...) |
||||
} |
@ -0,0 +1,96 @@
|
||||
/* |
||||
dela - web TODO list |
||||
Copyright (C) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte) |
||||
|
||||
This program is free software: you can redistribute it and/or modify |
||||
it under the terms of the GNU Affero General Public License as published by |
||||
the Free Software Foundation, either version 3 of the License, or |
||||
(at your option) any later version. |
||||
|
||||
This program is distributed in the hope that it will be useful, |
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
GNU Affero General Public License for more details. |
||||
|
||||
You should have received a copy of the GNU Affero General Public License |
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/ |
||||
|
||||
package main |
||||
|
||||
import ( |
||||
"Unbewohnte/dela/conf" |
||||
"Unbewohnte/dela/logger" |
||||
"Unbewohnte/dela/server" |
||||
"flag" |
||||
"fmt" |
||||
"os" |
||||
"path/filepath" |
||||
) |
||||
|
||||
const Version string = "0.1.2" |
||||
|
||||
var ( |
||||
printVersion *bool = flag.Bool("version", false, "Print version information and exit") |
||||
) |
||||
|
||||
const ConfName string = "conf.json" |
||||
|
||||
var ( |
||||
WDir string |
||||
Conf conf.Conf |
||||
) |
||||
|
||||
func init() { |
||||
// Parse flags
|
||||
flag.Parse() |
||||
if *printVersion { |
||||
fmt.Printf("dela v%s - a web TODO list\n(c) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte)\n", Version) |
||||
os.Exit(0) |
||||
} |
||||
|
||||
// Initialize logging
|
||||
logger.SetOutput(os.Stdout) |
||||
|
||||
// Work out the working directory
|
||||
exePath, err := os.Executable() |
||||
if err != nil { |
||||
logger.Error("[Init] Failed to retrieve executable's path: %s", err) |
||||
os.Exit(1) |
||||
} |
||||
WDir = filepath.Dir(exePath) |
||||
logger.Info("[Init] Working in \"%s\"", WDir) |
||||
|
||||
// Open configuration, create if does not exist
|
||||
Conf, err = conf.FromFile(filepath.Join(WDir, ConfName)) |
||||
if err != nil { |
||||
_, err = conf.Create(filepath.Join(WDir, ConfName), conf.Default()) |
||||
if err != nil { |
||||
logger.Error("[Init] Failed to create a new configuration file: %s", err) |
||||
os.Exit(1) |
||||
} |
||||
logger.Info("[Init] Created a new configuration file") |
||||
os.Exit(0) |
||||
} |
||||
logger.Info("[Init] Opened existing configuration file") |
||||
if Conf.BaseContentDir == "." { |
||||
Conf.BaseContentDir = WDir |
||||
} |
||||
|
||||
logger.Info("[Init] Successful initializaion!") |
||||
} |
||||
|
||||
func main() { |
||||
server, err := server.New(Conf) |
||||
if err != nil { |
||||
logger.Error("[Main] Failed to initialize a new server with conf (%+v): %s", Conf, err) |
||||
return |
||||
} |
||||
logger.Info("[Main] Successfully initialized a new server instance with conf (%+v)", Conf) |
||||
|
||||
err = server.Start() |
||||
if err != nil { |
||||
logger.Error("[Main] Fatal server failure: %s. Exiting...", err) |
||||
return |
||||
} |
||||
} |
@ -0,0 +1,146 @@
|
||||
/* |
||||
dela - web TODO list |
||||
Copyright (C) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte) |
||||
|
||||
This program is free software: you can redistribute it and/or modify |
||||
it under the terms of the GNU Affero General Public License as published by |
||||
the Free Software Foundation, either version 3 of the License, or |
||||
(at your option) any later version. |
||||
|
||||
This program is distributed in the hope that it will be useful, |
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
GNU Affero General Public License for more details. |
||||
|
||||
You should have received a copy of the GNU Affero General Public License |
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/ |
||||
|
||||
package server |
||||
|
||||
import ( |
||||
"Unbewohnte/dela/conf" |
||||
"Unbewohnte/dela/db" |
||||
"bytes" |
||||
"encoding/json" |
||||
"fmt" |
||||
"io" |
||||
"net/http" |
||||
"os" |
||||
"path/filepath" |
||||
"testing" |
||||
"time" |
||||
) |
||||
|
||||
func TestApi(t *testing.T) { |
||||
// Create a new server
|
||||
config := conf.Default() |
||||
config.BaseContentDir = "../../" |
||||
config.ProdDBName = filepath.Join(os.TempDir(), "dela_test_db.db") |
||||
server, err := New(config) |
||||
if err != nil { |
||||
t.Fatalf("failed to create a new server: %s", err) |
||||
} |
||||
defer os.Remove(config.ProdDBName) |
||||
|
||||
go func() { |
||||
time.Sleep(time.Second * 5) |
||||
server.Stop() |
||||
}() |
||||
|
||||
go func() { |
||||
server.Start() |
||||
}() |
||||
|
||||
// Create a new user
|
||||
newUser := db.User{ |
||||
Login: "user1", |
||||
Password: "ruohguoeruoger", |
||||
TimeCreatedUnix: 12421467, |
||||
} |
||||
newUserJsonBytes, err := json.Marshal(&newUser) |
||||
if err != nil { |
||||
t.Fatalf("could not marshal new user JSON: %s", err) |
||||
} |
||||
|
||||
resp, err := http.Post(fmt.Sprintf("http://localhost:%d/api/user", config.Port), "application/json", bytes.NewBuffer(newUserJsonBytes)) |
||||
if err != nil { |
||||
t.Fatalf("failed to post a new user data: %s", err) |
||||
} |
||||
body, err := io.ReadAll(resp.Body) |
||||
if err != nil { |
||||
t.Fatalf("failed to read response body for user creation: %s", err) |
||||
} |
||||
|
||||
if resp.StatusCode != http.StatusOK { |
||||
t.Fatalf("got non-OK status code for user creation: %s", string(body)) |
||||
} |
||||
resp.Body.Close() |
||||
|
||||
// Create a new TODO
|
||||
// newGroup := db.TodoGroup{
|
||||
// Name: "group1",
|
||||
// TimeCreatedUnix: 13524534,
|
||||
// OwnerUsername: newUser.Login,
|
||||
// }
|
||||
// newGroupBytes, err := json.Marshal(&newGroup)
|
||||
// if err != nil {
|
||||
// t.Fatalf("could not marshal new user JSON: %s", err)
|
||||
// }
|
||||
|
||||
// req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/groups", config.Port), bytes.NewBuffer(newGroupBytes))
|
||||
// if err != nil {
|
||||
// t.Fatalf("failed to create a new POST request to create a new TODO group: %s", err)
|
||||
// }
|
||||
// req.Header.Add(RequestHeaderAuthKey, fmt.Sprintf("%s%s%s", newUser.Login, RequestHeaderAuthSeparator, newUser.Password))
|
||||
// req.Header.Add(RequestHeaderEncodedB64, "false")
|
||||
|
||||
// resp, err = http.DefaultClient.Do(req)
|
||||
// if err != nil {
|
||||
// t.Fatalf("failed to post a new TODO group: %s", err)
|
||||
// }
|
||||
|
||||
body, err = io.ReadAll(resp.Body) |
||||
if err != nil { |
||||
t.Fatalf("failed to read response body for TODO group creation: %s", err) |
||||
} |
||||
|
||||
if resp.StatusCode != http.StatusOK { |
||||
t.Fatalf("got non-OK status code for TODO group creation: %s", string(body)) |
||||
} |
||||
resp.Body.Close() |
||||
|
||||
// TODO creation
|
||||
var newTodo db.Todo = db.Todo{ |
||||
GroupID: 0, |
||||
Text: "Do the dishes", |
||||
TimeCreatedUnix: uint64(time.Now().UnixMicro()), |
||||
DueUnix: uint64(time.Now().Add(time.Hour * 5).UnixMicro()), |
||||
OwnerLogin: newUser.Login, |
||||
} |
||||
|
||||
newTodoBytes, err := json.Marshal(&newTodo) |
||||
if err != nil { |
||||
t.Fatalf("could not marshal new Todo: %s", err) |
||||
} |
||||
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/todo", config.Port), bytes.NewBuffer(newTodoBytes)) |
||||
if err != nil { |
||||
t.Fatalf("failed to create a new POST request to create a new TODO: %s", err) |
||||
} |
||||
|
||||
resp, err = http.DefaultClient.Do(req) |
||||
if err != nil { |
||||
t.Fatalf("failed to post a new Todo: %s", err) |
||||
} |
||||
|
||||
body, err = io.ReadAll(resp.Body) |
||||
if err != nil { |
||||
t.Fatalf("failed to read response body for Todo creation: %s", err) |
||||
} |
||||
|
||||
if resp.StatusCode != http.StatusOK { |
||||
t.Fatalf("got non-OK status code for Todo creation: %s", string(body)) |
||||
} |
||||
resp.Body.Close() |
||||
} |
@ -0,0 +1,510 @@
|
||||
/* |
||||
dela - web TODO list |
||||
Copyright (C) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte) |
||||
|
||||
This program is free software: you can redistribute it and/or modify |
||||
it under the terms of the GNU Affero General Public License as published by |
||||
the Free Software Foundation, either version 3 of the License, or |
||||
(at your option) any later version. |
||||
|
||||
This program is distributed in the hope that it will be useful, |
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
GNU Affero General Public License for more details. |
||||
|
||||
You should have received a copy of the GNU Affero General Public License |
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/ |
||||
|
||||
package server |
||||
|
||||
import ( |
||||
"Unbewohnte/dela/db" |
||||
"Unbewohnte/dela/logger" |
||||
"encoding/json" |
||||
"io" |
||||
"net/http" |
||||
"path" |
||||
"strconv" |
||||
"time" |
||||
) |
||||
|
||||
func (s *Server) EndpointUserCreate(w http.ResponseWriter, req *http.Request) { |
||||
if req.Method != http.MethodPost { |
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) |
||||
return |
||||
} |
||||
|
||||
// Retrieve user data
|
||||
defer req.Body.Close() |
||||
|
||||
contents, err := io.ReadAll(req.Body) |
||||
if err != nil { |
||||
logger.Error("[Server][EndpointUserCreate] Failed to read request body: %s", err) |
||||
http.Error(w, "Failed to read request body", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
var user db.User |
||||
err = json.Unmarshal(contents, &user) |
||||
if err != nil { |
||||
logger.Error("[Server][EndpointUserCreate] Failed to unmarshal user data: %s", err) |
||||
http.Error(w, "User JSON unmarshal error", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Sanitize
|
||||
valid, reason := IsUserValid(user) |
||||
if !valid { |
||||
http.Error(w, reason, http.StatusInternalServerError) |
||||
return |
||||
} |
||||
user.TimeCreatedUnix = uint64(time.Now().Unix()) |
||||
|
||||
// Insert into DB
|
||||
err = s.db.CreateUser(user) |
||||
if err != nil { |
||||
logger.Error("[Server][EndpointUserCreate] Failed to insert new user \"%s\" data: %s", user.Login, err) |
||||
http.Error(w, "Failed to create user", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
logger.Info("[Server][EndpointUserCreate] Created a new user with login \"%s\"", user.Login) |
||||
w.WriteHeader(http.StatusOK) |
||||
} |
||||
|
||||
func (s *Server) EndpointUserUpdate(w http.ResponseWriter, req *http.Request) { |
||||
if req.Method != http.MethodPost { |
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) |
||||
} |
||||
|
||||
// Retrieve user data
|
||||
defer req.Body.Close() |
||||
|
||||
// Authentication check
|
||||
if !IsUserAuthorizedReq(req, s.db) { |
||||
http.Error(w, "Authentication error", http.StatusForbidden) |
||||
return |
||||
} |
||||
|
||||
contents, err := io.ReadAll(req.Body) |
||||
if err != nil { |
||||
logger.Error("[Server][EndpointUserUpdate] Failed to read request body: %s", err) |
||||
http.Error(w, "Failed to read request body", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
var user db.User |
||||
err = json.Unmarshal(contents, &user) |
||||
if err != nil { |
||||
logger.Error("[Server][EndpointUserUpdate] Failed to unmarshal user data: %s", err) |
||||
http.Error(w, "User JSON unmarshal error", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Check whether the user in request is the user specified in JSON
|
||||
login, _, _ := req.BasicAuth() |
||||
if login != user.Login { |
||||
// Gotcha!
|
||||
logger.Warning("[Server][EndpointUserUpdate] %s tried to update user information of %s!", login, user.Login) |
||||
http.Error(w, "Logins do not match", http.StatusForbidden) |
||||
return |
||||
} |
||||
|
||||
// Update
|
||||
err = s.db.UserUpdate(user) |
||||
if err != nil { |
||||
http.Error(w, "Failed to update user", http.StatusInternalServerError) |
||||
logger.Error("[Server][EndpointUserUpdate] Failed to update \"%s\": %s", user.Login, err) |
||||
return |
||||
} |
||||
|
||||
logger.Info("[Server][EndpointUserUpdate] Updated a user with login \"%s\"", user.Login) |
||||
w.WriteHeader(http.StatusOK) |
||||
} |
||||
|
||||
func (s *Server) EndpointUserDelete(w http.ResponseWriter, req *http.Request) { |
||||
if req.Method != http.MethodPost { |
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) |
||||
return |
||||
} |
||||
|
||||
defer req.Body.Close() |
||||
|
||||
// Authentication check
|
||||
if !IsUserAuthorizedReq(req, s.db) { |
||||
http.Error(w, "Authentication error", http.StatusForbidden) |
||||
return |
||||
} |
||||
|
||||
// Delete
|
||||
login, _, _ := req.BasicAuth() |
||||
err := s.db.DeleteUser(login) |
||||
if err != nil { |
||||
http.Error(w, "Failed to delete user", http.StatusInternalServerError) |
||||
logger.Error("[Server][EndpointUserDelete] Failed to delete \"%s\": %s", login, err) |
||||
return |
||||
} |
||||
|
||||
logger.Info("[Server][EndpointUserDelete] Deleted a user with login \"%s\"", login) |
||||
w.WriteHeader(http.StatusOK) |
||||
} |
||||
|
||||
func (s *Server) EndpointUserGet(w http.ResponseWriter, req *http.Request) { |
||||
if req.Method != http.MethodGet { |
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) |
||||
return |
||||
} |
||||
|
||||
defer req.Body.Close() |
||||
|
||||
// Authentication check
|
||||
if !IsUserAuthorizedReq(req, s.db) { |
||||
http.Error(w, "Authentication error", http.StatusForbidden) |
||||
return |
||||
} |
||||
|
||||
// Get information from the database
|
||||
login, _, _ := req.BasicAuth() |
||||
userDB, err := s.db.GetUser(login) |
||||
if err != nil { |
||||
logger.Error("[Server][EndpointUserGet] Failed to retrieve information on \"%s\": %s", login, err) |
||||
http.Error(w, "Failed to fetch information", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
userDBBytes, err := json.Marshal(&userDB) |
||||
if err != nil { |
||||
logger.Error("[Server][EndpointUserGet] Failed to marshal information on \"%s\": %s", login, err) |
||||
http.Error(w, "Failed to marshal information", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Send
|
||||
w.Write(userDBBytes) |
||||
} |
||||
|
||||
func (s *Server) EndpointTodoUpdate(w http.ResponseWriter, req *http.Request) { |
||||
defer req.Body.Close() |
||||
|
||||
if req.Method != http.MethodPost { |
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) |
||||
return |
||||
} |
||||
|
||||
// Check authentication information
|
||||
if !IsUserAuthorizedReq(req, s.db) { |
||||
http.Error(w, "Invalid user auth data", http.StatusForbidden) |
||||
return |
||||
} |
||||
|
||||
// Obtain TODO ID
|
||||
todoIDStr := path.Base(req.URL.Path) |
||||
todoID, err := strconv.ParseUint(todoIDStr, 10, 64) |
||||
if err != nil { |
||||
http.Error(w, "Invalid TODO ID", http.StatusBadRequest) |
||||
return |
||||
} |
||||
|
||||
// Check if the user owns this TODO
|
||||
if !s.db.DoesUserOwnTodo(todoID, GetLoginFromAuth(req)) { |
||||
http.Error(w, "You don't own this TODO", http.StatusForbidden) |
||||
return |
||||
} |
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(req.Body) |
||||
if err != nil { |
||||
logger.Warning("[Server] Failed to read request body to possibly update a TODO: %s", err) |
||||
http.Error(w, "Failed to read body", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Unmarshal JSON
|
||||
var updatedTodo db.Todo |
||||
err = json.Unmarshal(body, &updatedTodo) |
||||
if err != nil { |
||||
logger.Warning("[Server] Received invalid TODO JSON in order to update: %s", err) |
||||
http.Error(w, "Invalid TODO JSON", http.StatusBadRequest) |
||||
return |
||||
} |
||||
|
||||
// Update. (Creation date, owner username and an ID do not change)
|
||||
err = s.db.UpdateTodo(todoID, updatedTodo) |
||||
if err != nil { |
||||
logger.Warning("[Server] Failed to update TODO: %s", err) |
||||
http.Error(w, "Failed to update", http.StatusBadRequest) |
||||
return |
||||
} |
||||
w.WriteHeader(http.StatusOK) |
||||
logger.Info("[Server] Updated TODO with ID %d", todoID) |
||||
} |
||||
|
||||
func (s *Server) EndpointTodoDelete(w http.ResponseWriter, req *http.Request) { |
||||
defer req.Body.Close() |
||||
|
||||
if req.Method != http.MethodPost { |
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) |
||||
return |
||||
} |
||||
|
||||
// Delete an existing TODO
|
||||
|
||||
// Check if this user actually exists and the password is valid
|
||||
if !IsUserAuthorizedReq(req, s.db) { |
||||
http.Error(w, "Invalid user auth data", http.StatusForbidden) |
||||
return |
||||
} |
||||
|
||||
// Obtain TODO ID
|
||||
todoIDStr := path.Base(req.URL.Path) |
||||
todoID, err := strconv.ParseUint(todoIDStr, 10, 64) |
||||
if err != nil { |
||||
http.Error(w, "Invalid TODO ID", http.StatusBadRequest) |
||||
return |
||||
} |
||||
|
||||
// Check if the user owns this TODO
|
||||
if !s.db.DoesUserOwnTodo(todoID, GetLoginFromAuth(req)) { |
||||
http.Error(w, "You don't own this TODO", http.StatusForbidden) |
||||
return |
||||
} |
||||
|
||||
// Now delete
|
||||
err = s.db.DeleteTodo(todoID) |
||||
if err != nil { |
||||
logger.Error("[Server] Failed to delete %s's TODO: %s", GetLoginFromAuth(req), err) |
||||
http.Error(w, "Failed to delete TODO", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Success!
|
||||
logger.Info("[Server] Deleted TODO with ID %d", todoID) |
||||
w.WriteHeader(http.StatusOK) |
||||
} |
||||
|
||||
func (s *Server) EndpointTodoCreate(w http.ResponseWriter, req *http.Request) { |
||||
// Create a new TODO
|
||||
defer req.Body.Close() |
||||
// Read body
|
||||
body, err := io.ReadAll(req.Body) |
||||
if err != nil { |
||||
logger.Warning("[Server] Failed to read request body to create a new TODO: %s", err) |
||||
http.Error(w, "Failed to read body", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Unmarshal JSON
|
||||
var newTodo db.Todo |
||||
err = json.Unmarshal(body, &newTodo) |
||||
if err != nil { |
||||
logger.Warning("[Server] Received invalid TODO JSON for creation: %s", err) |
||||
http.Error(w, "Invalid TODO JSON", http.StatusBadRequest) |
||||
return |
||||
} |
||||
|
||||
// Check for authentication problems
|
||||
if !IsUserAuthorizedReq(req, s.db) { |
||||
http.Error(w, "Invalid user auth data", http.StatusForbidden) |
||||
return |
||||
} |
||||
|
||||
// Add TODO to the database
|
||||
newTodo.OwnerLogin = GetLoginFromAuth(req) |
||||
newTodo.TimeCreatedUnix = uint64(time.Now().Unix()) |
||||
err = s.db.CreateTodo(newTodo) |
||||
if err != nil { |
||||
http.Error(w, "Failed to create TODO", http.StatusInternalServerError) |
||||
logger.Error("[Server] Failed to put a new todo (%+v) into the db: %s", newTodo, err) |
||||
return |
||||
} |
||||
|
||||
// Success!
|
||||
w.WriteHeader(http.StatusOK) |
||||
logger.Info("[Server] Created a new TODO for %s", newTodo.OwnerLogin) |
||||
} |
||||
|
||||
func (s *Server) EndpointUserTodosGet(w http.ResponseWriter, req *http.Request) { |
||||
// Retrieve TODO information
|
||||
|
||||
defer req.Body.Close() |
||||
|
||||
// Authentication check
|
||||
if !IsUserAuthorizedReq(req, s.db) { |
||||
http.Error(w, "Authentication error", http.StatusForbidden) |
||||
return |
||||
} |
||||
|
||||
// Check authentication information
|
||||
if !IsUserAuthorizedReq(req, s.db) { |
||||
http.Error(w, "Invalid user auth data", http.StatusForbidden) |
||||
return |
||||
} |
||||
|
||||
// Get all user TODOs
|
||||
todos, err := s.db.GetAllUserTodos(GetLoginFromAuth(req)) |
||||
if err != nil { |
||||
http.Error(w, "Failed to get TODOs", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Marshal to JSON
|
||||
todosBytes, err := json.Marshal(&todos) |
||||
if err != nil { |
||||
http.Error(w, "Failed to marhsal TODOs JSON", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Send out
|
||||
w.Header().Add("Content-Type", "application/json") |
||||
w.Write(todosBytes) |
||||
} |
||||
|
||||
func (s *Server) EndpointTodoGroupDelete(w http.ResponseWriter, req *http.Request) { |
||||
// Delete an existing group
|
||||
defer req.Body.Close() |
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(req.Body) |
||||
if err != nil { |
||||
logger.Warning("[Server] Failed to read request body to possibly delete a TODO group: %s", err) |
||||
http.Error(w, "Failed to read body", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Unmarshal JSON
|
||||
var group db.TodoGroup |
||||
err = json.Unmarshal(body, &group) |
||||
if err != nil { |
||||
logger.Warning("[Server] Received invalid TODO group JSON for deletion: %s", err) |
||||
http.Error(w, "Invalid TODO group JSON", http.StatusBadRequest) |
||||
return |
||||
} |
||||
|
||||
// Check if given user actually owns this group
|
||||
if !IsUserAuthorizedReq(req, s.db) { |
||||
http.Error(w, "Invalid user auth data", http.StatusForbidden) |
||||
return |
||||
} |
||||
|
||||
if !s.db.DoesUserOwnGroup(group.ID, GetLoginFromAuth(req)) { |
||||
http.Error(w, "You don't own this group", http.StatusForbidden) |
||||
return |
||||
} |
||||
|
||||
// Now delete
|
||||
err = s.db.DeleteTodoGroup(group.ID) |
||||
if err != nil { |
||||
logger.Error("[Server] Failed to delete %s's TODO group: %s", GetLoginFromAuth(req), err) |
||||
http.Error(w, "Failed to delete TODO group", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Success!
|
||||
w.WriteHeader(http.StatusOK) |
||||
} |
||||
|
||||
func (s *Server) EndpointTodoGroupCreate(w http.ResponseWriter, req *http.Request) { |
||||
// Create a new TODO group
|
||||
defer req.Body.Close() |
||||
// Read body
|
||||
body, err := io.ReadAll(req.Body) |
||||
if err != nil { |
||||
logger.Warning("[Server] Failed to read request body to create a new TODO group: %s", err) |
||||
http.Error(w, "Failed to read body", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Unmarshal JSON
|
||||
var newGroup db.TodoGroup |
||||
err = json.Unmarshal(body, &newGroup) |
||||
if err != nil { |
||||
logger.Warning("[Server] Received invalid TODO group JSON for creation: %s", err) |
||||
http.Error(w, "Invalid TODO group JSON", http.StatusBadRequest) |
||||
return |
||||
} |
||||
|
||||
// Check for authentication problems
|
||||
if !IsUserAuthorizedReq(req, s.db) { |
||||
http.Error(w, "Invalid user auth data", http.StatusForbidden) |
||||
return |
||||
} |
||||
|
||||
// Add group to the database
|
||||
newGroup.OwnerLogin = GetLoginFromAuth(req) |
||||
newGroup.TimeCreatedUnix = uint64(time.Now().Unix()) |
||||
err = s.db.CreateTodoGroup(newGroup) |
||||
if err != nil { |
||||
http.Error(w, "Failed to create TODO group", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Success!
|
||||
w.WriteHeader(http.StatusOK) |
||||
logger.Info("[Server] Created a new TODO group for %s", newGroup.OwnerLogin) |
||||
|
||||
} |
||||
|
||||
func (s *Server) EndpointTodoGroupGet(w http.ResponseWriter, req *http.Request) { |
||||
// Retrieve all todo groups
|
||||
defer req.Body.Close() |
||||
|
||||
// Check authentication information
|
||||
if !IsUserAuthorizedReq(req, s.db) { |
||||
http.Error(w, "Invalid user auth data", http.StatusForbidden) |
||||
return |
||||
} |
||||
|
||||
// Get groups
|
||||
groups, err := s.db.GetAllUserTodoGroups(GetLoginFromAuth(req)) |
||||
if err != nil { |
||||
http.Error(w, "Failed to get TODO groups", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Marshal to JSON
|
||||
groupBytes, err := json.Marshal(&groups) |
||||
if err != nil { |
||||
http.Error(w, "Failed to marhsal TODO groups JSON", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Send out
|
||||
w.Header().Add("Content-Type", "application/json") |
||||
w.Write(groupBytes) |
||||
} |
||||
|
||||
func (s *Server) EndpointTodoGroupUpdate(w http.ResponseWriter, req *http.Request) { |
||||
// Check authentication information
|
||||
if !IsUserAuthorizedReq(req, s.db) { |
||||
http.Error(w, "Invalid user auth data", http.StatusForbidden) |
||||
return |
||||
} |
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(req.Body) |
||||
if err != nil { |
||||
logger.Warning("[Server] Failed to read request body to possibly update a TODO group: %s", err) |
||||
http.Error(w, "Failed to read body", http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
// Unmarshal JSON
|
||||
var group db.TodoGroup |
||||
err = json.Unmarshal(body, &group) |
||||
if err != nil { |
||||
logger.Warning("[Server] Received invalid TODO group JSON in order to update: %s", err) |
||||
http.Error(w, "Invalid group JSON", http.StatusBadRequest) |
||||
return |
||||
} |
||||
|
||||
// TODO
|
||||
err = s.db.UpdateTodoGroup(group.ID, group) |
||||
if err != nil { |
||||
logger.Warning("[Server] Failed to update TODO group: %s", err) |
||||
http.Error(w, "Failed to update", http.StatusBadRequest) |
||||
return |
||||
} |
||||
|
||||
w.WriteHeader(http.StatusOK) |
||||
} |
@ -0,0 +1,37 @@
|
||||
/* |
||||
dela - web TODO list |
||||
Copyright (C) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte) |
||||
|
||||
This program is free software: you can redistribute it and/or modify |
||||
it under the terms of the GNU Affero General Public License as published by |
||||
the Free Software Foundation, either version 3 of the License, or |
||||
(at your option) any later version. |
||||
|
||||
This program is distributed in the hope that it will be useful, |
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
GNU Affero General Public License for more details. |
||||
|
||||
You should have received a copy of the GNU Affero General Public License |
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/ |
||||
|
||||
package server |
||||
|
||||
import ( |
||||
"html/template" |
||||
"path/filepath" |
||||
) |
||||
|
||||
// Constructs a pageName template via inserting basePageName in pagesDir
|
||||
func getPage(pagesDir string, basePageName string, pageName string) (*template.Template, error) { |
||||
page, err := template.ParseFiles( |
||||
filepath.Join(pagesDir, basePageName), |
||||
filepath.Join(pagesDir, pageName), |
||||
) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return page, nil |
||||
} |
@ -0,0 +1,178 @@
|
||||
/* |
||||
dela - web TODO list |
||||
Copyright (C) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte) |
||||
|
||||
This program is free software: you can redistribute it and/or modify |
||||
it under the terms of the GNU Affero General Public License as published by |
||||
the Free Software Foundation, either version 3 of the License, or |
||||
(at your option) any later version. |
||||
|
||||
This program is distributed in the hope that it will be useful, |
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
GNU Affero General Public License for more details. |
||||
|
||||
You should have received a copy of the GNU Affero General Public License |
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/ |
||||
|
||||
package server |
||||
|
||||
import ( |
||||
"Unbewohnte/dela/conf" |
||||
"Unbewohnte/dela/db" |
||||
"Unbewohnte/dela/logger" |
||||
"context" |
||||
"fmt" |
||||
"net/http" |
||||
"os" |
||||
"path/filepath" |
||||
"time" |
||||
) |
||||
|
||||
const ( |
||||
PagesDirName string = "pages" |
||||
StaticDirName string = "static" |
||||
ScriptsDirName string = "scripts" |
||||
) |
||||
|
||||
type Server struct { |
||||
config conf.Conf |
||||
db *db.DB |
||||
http http.Server |
||||
} |
||||
|
||||
// Creates a new server instance with provided config
|
||||
func New(config conf.Conf) (*Server, error) { |
||||
var server Server = Server{} |
||||
server.config = config |
||||
|
||||
// check if required directories are present
|
||||
_, err := os.Stat(filepath.Join(config.BaseContentDir, PagesDirName)) |
||||
if err != nil { |
||||
logger.Error("[Server] A directory with HTML pages is not available: %s", err) |
||||
return nil, err |
||||
} |
||||
|
||||
_, err = os.Stat(filepath.Join(config.BaseContentDir, ScriptsDirName)) |
||||
if err != nil { |
||||
logger.Error("[Server] A directory with scripts is not available: %s", err) |
||||
return nil, err |
||||
} |
||||
|
||||
_, err = os.Stat(filepath.Join(config.BaseContentDir, StaticDirName)) |
||||
if err != nil { |
||||
logger.Error("[Server] A directory with static content is not available: %s", err) |
||||
return nil, err |
||||
} |
||||
|
||||
// get database working
|
||||
serverDB, err := db.FromFile(filepath.Join(config.BaseContentDir, config.ProdDBName)) |
||||
if err != nil { |
||||
// Create one then
|
||||
serverDB, err = db.Create(filepath.Join(config.BaseContentDir, config.ProdDBName)) |
||||
if err != nil { |
||||
logger.Error("Failed to create a new database: %s", err) |
||||
return nil, err |
||||
} |
||||
} |
||||
server.db = serverDB |
||||
logger.Info("Opened a database successfully") |
||||
|
||||
// start constructing an http server configuration
|
||||
server.http = http.Server{ |
||||
Addr: fmt.Sprintf(":%d", server.config.Port), |
||||
} |
||||
|
||||
// configure paths' callbacks
|
||||
mux := http.NewServeMux() |
||||
mux.Handle( |
||||
"/static/", |
||||
http.StripPrefix("/static/", http.FileServer( |
||||
http.Dir(filepath.Join(server.config.BaseContentDir, StaticDirName))), |
||||
), |
||||
) |
||||
|
||||
mux.Handle( |
||||
"/scripts/", |
||||
http.StripPrefix("/scripts/", http.FileServer( |
||||
http.Dir(filepath.Join(server.config.BaseContentDir, ScriptsDirName))), |
||||
), |
||||
) |
||||
|
||||
// handle page requests
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { |
||||
switch req.URL.Path { |
||||
case "/": |
||||
requestedPage, err := getPage( |
||||
filepath.Join(server.config.BaseContentDir, PagesDirName), "base.html", "index.html", |
||||
) |
||||
if err != nil { |
||||
http.Redirect(w, req, "/about", http.StatusTemporaryRedirect) |
||||
logger.Error("[Server][/] Failed to get a page: %s", err) |
||||
return |
||||
} |
||||
|
||||
requestedPage.ExecuteTemplate(w, "index.html", nil) |
||||
default: |
||||
requestedPage, err := getPage( |
||||
filepath.Join(server.config.BaseContentDir, PagesDirName), |
||||
"base.html", |
||||
req.URL.Path[1:]+".html", |
||||
) |
||||
if err == nil { |
||||
requestedPage.ExecuteTemplate(w, req.URL.Path[1:]+".html", nil) |
||||
} else { |
||||
http.Error(w, "Page processing error", http.StatusInternalServerError) |
||||
} |
||||
} |
||||
}) |
||||
mux.HandleFunc("/api/user/get", server.EndpointUserGet) // Non specific
|
||||
mux.HandleFunc("/api/user/delete", server.EndpointUserDelete) // Non specific
|
||||
mux.HandleFunc("/api/user/update", server.EndpointUserUpdate) // Non specific
|
||||
mux.HandleFunc("/api/user/create", server.EndpointUserCreate) // Non specific
|
||||
mux.HandleFunc("/api/todo/get", server.EndpointUserTodosGet) // Non specific
|
||||
mux.HandleFunc("/api/todo/delete/", server.EndpointTodoDelete) // Specific
|
||||
mux.HandleFunc("/api/todo/update/", server.EndpointTodoUpdate) // Specific
|
||||
mux.HandleFunc("/api/group/create", server.EndpointTodoGroupCreate) // Non specific
|
||||
mux.HandleFunc("/api/group/get/", server.EndpointTodoGroupGet) // Specific
|
||||
mux.HandleFunc("/api/group/update/", server.EndpointTodoGroupUpdate) // Specific
|
||||
mux.HandleFunc("/api/group/delete/", server.EndpointTodoGroupDelete) // Specific
|
||||
|
||||
server.http.Handler = mux |
||||
logger.Info("[Server] Created an HTTP server instance") |
||||
|
||||
return &server, nil |
||||
} |
||||
|
||||
// Launches server instance
|
||||
func (s *Server) Start() error { |
||||
if s.config.CertFilePath != "" && s.config.KeyFilePath != "" { |
||||
logger.Info("[Server] Using TLS") |
||||
logger.Info("[Server] HTTP server is going live on port %d!", s.config.Port) |
||||
|
||||
err := s.http.ListenAndServeTLS(s.config.CertFilePath, s.config.KeyFilePath) |
||||
if err != nil && err != http.ErrServerClosed { |
||||
logger.Error("[Server] Fatal server error: %s", err) |
||||
return err |
||||
} |
||||
} else { |
||||
logger.Info("[Server] Not using TLS") |
||||
logger.Info("[Server] HTTP server is going live on port %d!", s.config.Port) |
||||
|
||||
err := s.http.ListenAndServe() |
||||
if err != nil && err != http.ErrServerClosed { |
||||
logger.Error("[Server] Fatal server error: %s", err) |
||||
return err |
||||
} |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
// Stops the server immediately
|
||||
func (s *Server) Stop() { |
||||
ctx, cfunc := context.WithDeadline(context.Background(), time.Now().Add(time.Second*10)) |
||||
s.http.Shutdown(ctx) |
||||
cfunc() |
||||
} |
@ -0,0 +1,86 @@
|
||||
/* |
||||
dela - web TODO list |
||||
Copyright (C) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte) |
||||
|
||||
This program is free software: you can redistribute it and/or modify |
||||
it under the terms of the GNU Affero General Public License as published by |
||||
the Free Software Foundation, either version 3 of the License, or |
||||
(at your option) any later version. |
||||
|
||||
This program is distributed in the hope that it will be useful, |
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
GNU Affero General Public License for more details. |
||||
|
||||
You should have received a copy of the GNU Affero General Public License |
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/ |
||||
|
||||
package server |
||||
|
||||
import ( |
||||
"Unbewohnte/dela/db" |
||||
"net/http" |
||||
) |
||||
|
||||
const ( |
||||
MinimalLoginLength uint = 3 |
||||
MinimalUsernameLength uint = 3 |
||||
MinimalPasswordLength uint = 5 |
||||
) |
||||
|
||||
// Check if user is valid. Returns false and a reason-string if not
|
||||
func IsUserValid(user db.User) (bool, string) { |
||||
if uint(len(user.Login)) < MinimalLoginLength { |
||||
return false, "Login is too small" |
||||
} |
||||
|
||||
if uint(len(user.Password)) < MinimalPasswordLength { |
||||
return false, "Password is too small" |
||||
} |
||||
|
||||
for _, char := range user.Password { |
||||
if char < 0x21 || char > 0x7E { |
||||
// Not printable ASCII char!
|
||||
return false, "Password has a non printable ASCII character" |
||||
} |
||||
} |
||||
|
||||
return true, "" |
||||
} |
||||
|
||||
// Checks if such a user exists and compares passwords. Returns true if such user exists and passwords do match
|
||||
func IsUserAuthorized(db *db.DB, user db.User) bool { |
||||
userDB, err := db.GetUser(user.Login) |
||||
if err != nil { |
||||
return false |
||||
} |
||||
|
||||
if userDB.Password != user.Password { |
||||
return false |
||||
} |
||||
|
||||
return true |
||||
} |
||||
|
||||
/* |
||||
Gets auth information from a request and |
||||
checks if such a user exists and compares passwords. |
||||
Returns true if such user exists and passwords do match |
||||
*/ |
||||
func IsUserAuthorizedReq(req *http.Request, dbase *db.DB) bool { |
||||
login, password, ok := req.BasicAuth() |
||||
if !ok { |
||||
return false |
||||
} |
||||
|
||||
return IsUserAuthorized(dbase, db.User{ |
||||
Login: login, |
||||
Password: password, |
||||
}) |
||||
} |
||||
|
||||
func GetLoginFromAuth(req *http.Request) string { |
||||
login, _, _ := req.BasicAuth() |
||||
return login |
||||
} |
@ -0,0 +1,593 @@
|
||||
/*! |
||||
* Bootstrap Reboot v5.3.0 (https://getbootstrap.com/) |
||||
* Copyright 2011-2023 The Bootstrap Authors |
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) |
||||
*/ |
||||
:root, |
||||
[data-bs-theme=light] { |
||||
--bs-blue: #0d6efd; |
||||
--bs-indigo: #6610f2; |
||||
--bs-purple: #6f42c1; |
||||
--bs-pink: #d63384; |
||||
--bs-red: #dc3545; |
||||
--bs-orange: #fd7e14; |
||||
--bs-yellow: #ffc107; |
||||
--bs-green: #198754; |
||||
--bs-teal: #20c997; |
||||
--bs-cyan: #0dcaf0; |
||||
--bs-black: #000; |
||||
--bs-white: #fff; |
||||
--bs-gray: #6c757d; |
||||
--bs-gray-dark: #343a40; |
||||
--bs-gray-100: #f8f9fa; |
||||
--bs-gray-200: #e9ecef; |
||||
--bs-gray-300: #dee2e6; |
||||
--bs-gray-400: #ced4da; |
||||
--bs-gray-500: #adb5bd; |
||||
--bs-gray-600: #6c757d; |
||||
--bs-gray-700: #495057; |
||||
--bs-gray-800: #343a40; |
||||
--bs-gray-900: #212529; |
||||
--bs-primary: #0d6efd; |
||||
--bs-secondary: #6c757d; |
||||
--bs-success: #198754; |
||||
--bs-info: #0dcaf0; |
||||
--bs-warning: #ffc107; |
||||
--bs-danger: #dc3545; |
||||
--bs-light: #f8f9fa; |
||||
--bs-dark: #212529; |
||||
--bs-primary-rgb: 13, 110, 253; |
||||
--bs-secondary-rgb: 108, 117, 125; |
||||
--bs-success-rgb: 25, 135, 84; |
||||
--bs-info-rgb: 13, 202, 240; |
||||
--bs-warning-rgb: 255, 193, 7; |
||||
--bs-danger-rgb: 220, 53, 69; |
||||
--bs-light-rgb: 248, 249, 250; |
||||
--bs-dark-rgb: 33, 37, 41; |
||||
--bs-primary-text-emphasis: #052c65; |
||||
--bs-secondary-text-emphasis: #2b2f32; |
||||
--bs-success-text-emphasis: #0a3622; |
||||
--bs-info-text-emphasis: #055160; |
||||
--bs-warning-text-emphasis: #664d03; |
||||
--bs-danger-text-emphasis: #58151c; |
||||
--bs-light-text-emphasis: #495057; |
||||
--bs-dark-text-emphasis: #495057; |
||||
--bs-primary-bg-subtle: #cfe2ff; |
||||
--bs-secondary-bg-subtle: #e2e3e5; |
||||
--bs-success-bg-subtle: #d1e7dd; |
||||
--bs-info-bg-subtle: #cff4fc; |
||||
--bs-warning-bg-subtle: #fff3cd; |
||||
--bs-danger-bg-subtle: #f8d7da; |
||||
--bs-light-bg-subtle: #fcfcfd; |
||||
--bs-dark-bg-subtle: #ced4da; |
||||
--bs-primary-border-subtle: #9ec5fe; |
||||
--bs-secondary-border-subtle: #c4c8cb; |
||||
--bs-success-border-subtle: #a3cfbb; |
||||
--bs-info-border-subtle: #9eeaf9; |
||||
--bs-warning-border-subtle: #ffe69c; |
||||
--bs-danger-border-subtle: #f1aeb5; |
||||
--bs-light-border-subtle: #e9ecef; |
||||
--bs-dark-border-subtle: #adb5bd; |
||||
--bs-white-rgb: 255, 255, 255; |
||||
--bs-black-rgb: 0, 0, 0; |
||||
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; |
||||
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; |
||||
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0)); |
||||
--bs-body-font-family: var(--bs-font-sans-serif); |
||||
--bs-body-font-size: 1rem; |
||||
--bs-body-font-weight: 400; |
||||
--bs-body-line-height: 1.5; |
||||
--bs-body-color: #212529; |
||||
--bs-body-color-rgb: 33, 37, 41; |
||||
--bs-body-bg: #fff; |
||||
--bs-body-bg-rgb: 255, 255, 255; |
||||
--bs-emphasis-color: #000; |
||||
--bs-emphasis-color-rgb: 0, 0, 0; |
||||
--bs-secondary-color: rgba(33, 37, 41, 0.75); |
||||
--bs-secondary-color-rgb: 33, 37, 41; |
||||
--bs-secondary-bg: #e9ecef; |
||||
--bs-secondary-bg-rgb: 233, 236, 239; |
||||
--bs-tertiary-color: rgba(33, 37, 41, 0.5); |
||||
--bs-tertiary-color-rgb: 33, 37, 41; |
||||
--bs-tertiary-bg: #f8f9fa; |
||||
--bs-tertiary-bg-rgb: 248, 249, 250; |
||||
--bs-heading-color: inherit; |
||||
--bs-link-color: #0d6efd; |
||||
--bs-link-color-rgb: 13, 110, 253; |
||||
--bs-link-decoration: underline; |
||||
--bs-link-hover-color: #0a58ca; |
||||
--bs-link-hover-color-rgb: 10, 88, 202; |
||||
--bs-code-color: #d63384; |
||||
--bs-highlight-bg: #fff3cd; |
||||
--bs-border-width: 1px; |
||||
--bs-border-style: solid; |
||||
--bs-border-color: #dee2e6; |
||||
--bs-border-color-translucent: rgba(0, 0, 0, 0.175); |
||||
--bs-border-radius: 0.375rem; |
||||
--bs-border-radius-sm: 0.25rem; |
||||
--bs-border-radius-lg: 0.5rem; |
||||
--bs-border-radius-xl: 1rem; |
||||
--bs-border-radius-xxl: 2rem; |
||||
--bs-border-radius-2xl: var(--bs-border-radius-xxl); |
||||
--bs-border-radius-pill: 50rem; |
||||
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); |
||||
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); |
||||
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175); |
||||
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075); |
||||
--bs-focus-ring-width: 0.25rem; |
||||
--bs-focus-ring-opacity: 0.25; |
||||
--bs-focus-ring-color: rgba(13, 110, 253, 0.25); |
||||
--bs-form-valid-color: #198754; |
||||
--bs-form-valid-border-color: #198754; |
||||
--bs-form-invalid-color: #dc3545; |
||||
--bs-form-invalid-border-color: #dc3545; |
||||
} |
||||
|
||||
[data-bs-theme=dark] { |
||||
color-scheme: dark; |
||||
--bs-body-color: #adb5bd; |
||||
--bs-body-color-rgb: 173, 181, 189; |
||||
--bs-body-bg: #212529; |
||||
--bs-body-bg-rgb: 33, 37, 41; |
||||
--bs-emphasis-color: #fff; |
||||
--bs-emphasis-color-rgb: 255, 255, 255; |
||||
--bs-secondary-color: rgba(173, 181, 189, 0.75); |
||||
--bs-secondary-color-rgb: 173, 181, 189; |
||||
--bs-secondary-bg: #343a40; |
||||
--bs-secondary-bg-rgb: 52, 58, 64; |
||||
--bs-tertiary-color: rgba(173, 181, 189, 0.5); |
||||
--bs-tertiary-color-rgb: 173, 181, 189; |
||||
--bs-tertiary-bg: #2b3035; |
||||
--bs-tertiary-bg-rgb: 43, 48, 53; |
||||
--bs-primary-text-emphasis: #6ea8fe; |
||||
--bs-secondary-text-emphasis: #a7acb1; |
||||
--bs-success-text-emphasis: #75b798; |
||||
--bs-info-text-emphasis: #6edff6; |
||||
--bs-warning-text-emphasis: #ffda6a; |
||||
--bs-danger-text-emphasis: #ea868f; |
||||
--bs-light-text-emphasis: #f8f9fa; |
||||
--bs-dark-text-emphasis: #dee2e6; |
||||
--bs-primary-bg-subtle: #031633; |
||||
--bs-secondary-bg-subtle: #161719; |
||||
--bs-success-bg-subtle: #051b11; |
||||
--bs-info-bg-subtle: #032830; |
||||
--bs-warning-bg-subtle: #332701; |
||||
--bs-danger-bg-subtle: #2c0b0e; |
||||
--bs-light-bg-subtle: #343a40; |
||||
--bs-dark-bg-subtle: #1a1d20; |
||||
--bs-primary-border-subtle: #084298; |
||||
--bs-secondary-border-subtle: #41464b; |
||||
--bs-success-border-subtle: #0f5132; |
||||
--bs-info-border-subtle: #087990; |
||||
--bs-warning-border-subtle: #997404; |
||||
--bs-danger-border-subtle: #842029; |
||||
--bs-light-border-subtle: #495057; |
||||
--bs-dark-border-subtle: #343a40; |
||||
--bs-heading-color: inherit; |
||||
--bs-link-color: #6ea8fe; |
||||
--bs-link-hover-color: #8bb9fe; |
||||
--bs-link-color-rgb: 110, 168, 254; |
||||
--bs-link-hover-color-rgb: 139, 185, 254; |
||||
--bs-code-color: #e685b5; |
||||
--bs-border-color: #495057; |
||||
--bs-border-color-translucent: rgba(255, 255, 255, 0.15); |
||||
--bs-form-valid-color: #75b798; |
||||
--bs-form-valid-border-color: #75b798; |
||||
--bs-form-invalid-color: #ea868f; |
||||
--bs-form-invalid-border-color: #ea868f; |
||||
} |
||||
|
||||
*, |
||||
*::before, |
||||
*::after { |
||||
box-sizing: border-box; |
||||
} |
||||
|
||||
@media (prefers-reduced-motion: no-preference) { |
||||
:root { |
||||
scroll-behavior: smooth; |
||||
} |
||||
} |
||||
|
||||
body { |
||||
margin: 0; |
||||
font-family: var(--bs-body-font-family); |
||||
font-size: var(--bs-body-font-size); |
||||
font-weight: var(--bs-body-font-weight); |
||||
line-height: var(--bs-body-line-height); |
||||
color: var(--bs-body-color); |
||||
text-align: var(--bs-body-text-align); |
||||
background-color: var(--bs-body-bg); |
||||
-webkit-text-size-adjust: 100%; |
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0); |
||||
} |
||||
|
||||
hr { |
||||
margin: 1rem 0; |
||||
color: inherit; |
||||
border: 0; |
||||
border-top: var(--bs-border-width) solid; |
||||
opacity: 0.25; |
||||
} |
||||
|
||||
h6, h5, h4, h3, h2, h1 { |
||||
margin-top: 0; |
||||
margin-bottom: 0.5rem; |
||||
font-weight: 500; |
||||
line-height: 1.2; |
||||
color: var(--bs-heading-color); |
||||
} |
||||
|
||||
h1 { |
||||
font-size: calc(1.375rem + 1.5vw); |
||||
} |
||||
@media (min-width: 1200px) { |
||||
h1 { |
||||
font-size: 2.5rem; |
||||
} |
||||
} |
||||
|
||||
h2 { |
||||
font-size: calc(1.325rem + 0.9vw); |
||||
} |
||||
@media (min-width: 1200px) { |
||||
h2 { |
||||
font-size: 2rem; |
||||
} |
||||
} |
||||
|
||||
h3 { |
||||
font-size: calc(1.3rem + 0.6vw); |
||||
} |
||||
@media (min-width: 1200px) { |
||||
h3 { |
||||
font-size: 1.75rem; |
||||
} |
||||
} |
||||
|
||||
h4 { |
||||
font-size: calc(1.275rem + 0.3vw); |
||||
} |
||||
@media (min-width: 1200px) { |
||||
h4 { |
||||
font-size: 1.5rem; |
||||
} |
||||
} |
||||
|
||||
h5 { |
||||
font-size: 1.25rem; |
||||
} |
||||
|
||||
h6 { |
||||
font-size: 1rem; |
||||
} |
||||
|
||||
p { |
||||
margin-top: 0; |
||||
margin-bottom: 1rem; |
||||
} |
||||
|
||||
abbr[title] { |
||||
-webkit-text-decoration: underline dotted; |
||||
text-decoration: underline dotted; |
||||
cursor: help; |
||||
-webkit-text-decoration-skip-ink: none; |
||||
text-decoration-skip-ink: none; |
||||
} |
||||
|
||||
address { |
||||
margin-bottom: 1rem; |
||||
font-style: normal; |
||||
line-height: inherit; |
||||
} |
||||
|
||||
ol, |
||||
ul { |
||||
padding-left: 2rem; |
||||
} |
||||
|
||||
ol, |
||||
ul, |
||||
dl { |
||||
margin-top: 0; |
||||
margin-bottom: 1rem; |
||||
} |
||||
|
||||
ol ol, |
||||
ul ul, |
||||
ol ul, |
||||
ul ol { |
||||
margin-bottom: 0; |
||||
} |
||||
|
||||
dt { |
||||
font-weight: 700; |
||||
} |
||||
|
||||
dd { |
||||
margin-bottom: 0.5rem; |
||||
margin-left: 0; |
||||
} |
||||
|
||||
blockquote { |
||||
margin: 0 0 1rem; |
||||
} |
||||
|
||||
b, |
||||
strong { |
||||
font-weight: bolder; |
||||
} |
||||
|
||||
small { |
||||
font-size: 0.875em; |
||||
} |
||||
|
||||
mark { |
||||
padding: 0.1875em; |
||||
background-color: var(--bs-highlight-bg); |
||||
} |
||||
|
||||
sub, |
||||
sup { |
||||
position: relative; |
||||
font-size: 0.75em; |
||||
line-height: 0; |
||||
vertical-align: baseline; |
||||
} |
||||
|
||||
sub { |
||||
bottom: -0.25em; |
||||
} |
||||
|
||||
sup { |
||||
top: -0.5em; |
||||
} |
||||
|
||||
a { |
||||
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1)); |
||||
text-decoration: underline; |
||||
} |
||||
a:hover { |
||||
--bs-link-color-rgb: var(--bs-link-hover-color-rgb); |
||||
} |
||||
|
||||
a:not([href]):not([class]), a:not([href]):not([class]):hover { |
||||
color: inherit; |
||||
text-decoration: none; |
||||
} |
||||
|
||||
pre, |
||||
code, |
||||
kbd, |
||||
samp { |
||||
font-family: var(--bs-font-monospace); |
||||
font-size: 1em; |
||||
} |
||||
|
||||
pre { |
||||
display: block; |
||||
margin-top: 0; |
||||
margin-bottom: 1rem; |
||||
overflow: auto; |
||||
font-size: 0.875em; |
||||
} |
||||
pre code { |
||||
font-size: inherit; |
||||
color: inherit; |
||||
word-break: normal; |
||||
} |
||||
|
||||
code { |
||||
font-size: 0.875em; |
||||
color: var(--bs-code-color); |
||||
word-wrap: break-word; |
||||
} |
||||
a > code { |
||||
color: inherit; |
||||
} |
||||
|
||||
kbd { |
||||
padding: 0.1875rem 0.375rem; |
||||
font-size: 0.875em; |
||||
color: var(--bs-body-bg); |
||||
background-color: var(--bs-body-color); |
||||
border-radius: 0.25rem; |
||||
} |
||||
kbd kbd { |
||||
padding: 0; |
||||
font-size: 1em; |
||||
} |
||||
|
||||
figure { |
||||
margin: 0 0 1rem; |
||||
} |
||||
|
||||
img, |
||||
svg { |
||||
vertical-align: middle; |
||||
} |
||||
|
||||
table { |
||||
caption-side: bottom; |
||||
border-collapse: collapse; |
||||
} |
||||
|
||||
caption { |
||||
padding-top: 0.5rem; |
||||
padding-bottom: 0.5rem; |
||||
color: var(--bs-secondary-color); |
||||
text-align: left; |
||||
} |
||||
|
||||
th { |
||||
text-align: inherit; |
||||
text-align: -webkit-match-parent; |
||||
} |
||||
|
||||
thead, |
||||
tbody, |
||||
tfoot, |
||||
tr, |
||||
td, |
||||
th { |
||||
border-color: inherit; |
||||
border-style: solid; |
||||
border-width: 0; |
||||
} |
||||
|
||||
label { |
||||
display: inline-block; |
||||
} |
||||
|
||||
button { |
||||
border-radius: 0; |
||||
} |
||||
|
||||
button:focus:not(:focus-visible) { |
||||
outline: 0; |
||||
} |
||||
|
||||
input, |
||||
button, |
||||
select, |
||||
optgroup, |
||||
textarea { |
||||
margin: 0; |
||||
font-family: inherit; |
||||
font-size: inherit; |
||||
line-height: inherit; |
||||
} |
||||
|
||||
button, |
||||
select { |
||||
text-transform: none; |
||||
} |
||||
|
||||
[role=button] { |
||||
cursor: pointer; |
||||
} |
||||
|
||||
select { |
||||
word-wrap: normal; |
||||
} |
||||
select:disabled { |
||||
opacity: 1; |
||||
} |
||||
|
||||
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator { |
||||
display: none !important; |
||||
} |
||||
|
||||
button, |
||||
[type=button], |
||||
[type=reset], |
||||
[type=submit] { |
||||
-webkit-appearance: button; |
||||
} |
||||
button:not(:disabled), |
||||
[type=button]:not(:disabled), |
||||
[type=reset]:not(:disabled), |
||||
[type=submit]:not(:disabled) { |
||||
cursor: pointer; |
||||
} |
||||
|
||||
::-moz-focus-inner { |
||||
padding: 0; |
||||
border-style: none; |
||||
} |
||||
|
||||
textarea { |
||||
resize: vertical; |
||||
} |
||||
|
||||
fieldset { |
||||
min-width: 0; |
||||
padding: 0; |
||||
margin: 0; |
||||
border: 0; |
||||
} |
||||
|
||||
legend { |
||||
float: left; |
||||
width: 100%; |
||||
padding: 0; |
||||
margin-bottom: 0.5rem; |
||||
font-size: calc(1.275rem + 0.3vw); |
||||
line-height: inherit; |
||||
} |
||||
@media (min-width: 1200px) { |
||||
legend { |
||||
font-size: 1.5rem; |
||||
} |
||||
} |
||||
legend + * { |
||||
clear: left; |
||||
} |
||||
|
||||
::-webkit-datetime-edit-fields-wrapper, |
||||
::-webkit-datetime-edit-text, |
||||
::-webkit-datetime-edit-minute, |
||||
::-webkit-datetime-edit-hour-field, |
||||
::-webkit-datetime-edit-day-field, |
||||
::-webkit-datetime-edit-month-field, |
||||
::-webkit-datetime-edit-year-field { |
||||
padding: 0; |
||||
} |
||||
|
||||
::-webkit-inner-spin-button { |
||||
height: auto; |
||||
} |
||||
|
||||
[type=search] { |
||||
outline-offset: -2px; |
||||
-webkit-appearance: textfield; |
||||
} |
||||
|
||||
/* rtl:raw: |
||||
[type="tel"], |
||||
[type="url"], |
||||
[type="email"], |
||||
[type="number"] { |
||||
direction: ltr; |
||||
} |
||||
*/ |
||||
::-webkit-search-decoration { |
||||
-webkit-appearance: none; |
||||
} |
||||
|
||||
::-webkit-color-swatch-wrapper { |
||||
padding: 0; |
||||
} |
||||
|
||||
::-webkit-file-upload-button { |
||||
font: inherit; |
||||
-webkit-appearance: button; |
||||
} |
||||
|
||||
::file-selector-button { |
||||
font: inherit; |
||||
-webkit-appearance: button; |
||||
} |
||||
|
||||
output { |
||||
display: inline-block; |
||||
} |
||||
|
||||
iframe { |
||||
border: 0; |
||||
} |
||||
|
||||
summary { |
||||
display: list-item; |
||||
cursor: pointer; |
||||
} |
||||
|
||||
progress { |
||||
vertical-align: baseline; |
||||
} |
||||
|
||||
[hidden] { |
||||
display: none !important; |
||||
} |
||||
|
||||
/*# sourceMappingURL=bootstrap-reboot.css.map */ |
@ -0,0 +1,590 @@
|
||||
/*! |
||||
* Bootstrap Reboot v5.3.0 (https://getbootstrap.com/) |
||||
* Copyright 2011-2023 The Bootstrap Authors |
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) |
||||
*/ |
||||
:root, |
||||
[data-bs-theme=light] { |
||||
--bs-blue: #0d6efd; |
||||
--bs-indigo: #6610f2; |
||||
--bs-purple: #6f42c1; |
||||
--bs-pink: #d63384; |
||||
--bs-red: #dc3545; |
||||
--bs-orange: #fd7e14; |
||||
--bs-yellow: #ffc107; |
||||
--bs-green: #198754; |
||||
--bs-teal: #20c997; |
||||
--bs-cyan: #0dcaf0; |
||||
--bs-black: #000; |
||||
--bs-white: #fff; |
||||
--bs-gray: #6c757d; |
||||
--bs-gray-dark: #343a40; |
||||
--bs-gray-100: #f8f9fa; |
||||
--bs-gray-200: #e9ecef; |
||||
--bs-gray-300: #dee2e6; |
||||
--bs-gray-400: #ced4da; |
||||
--bs-gray-500: #adb5bd; |
||||
--bs-gray-600: #6c757d; |
||||
--bs-gray-700: #495057; |
||||
--bs-gray-800: #343a40; |
||||
--bs-gray-900: #212529; |
||||
--bs-primary: #0d6efd; |
||||
--bs-secondary: #6c757d; |
||||
--bs-success: #198754; |
||||
--bs-info: #0dcaf0; |
||||
--bs-warning: #ffc107; |
||||
--bs-danger: #dc3545; |
||||
--bs-light: #f8f9fa; |
||||
--bs-dark: #212529; |
||||
--bs-primary-rgb: 13, 110, 253; |
||||
--bs-secondary-rgb: 108, 117, 125; |
||||
--bs-success-rgb: 25, 135, 84; |
||||
--bs-info-rgb: 13, 202, 240; |
||||
--bs-warning-rgb: 255, 193, 7; |
||||
--bs-danger-rgb: 220, 53, 69; |
||||
--bs-light-rgb: 248, 249, 250; |
||||
--bs-dark-rgb: 33, 37, 41; |
||||
--bs-primary-text-emphasis: #052c65; |
||||
--bs-secondary-text-emphasis: #2b2f32; |
||||
--bs-success-text-emphasis: #0a3622; |
||||
--bs-info-text-emphasis: #055160; |
||||
--bs-warning-text-emphasis: #664d03; |
||||
--bs-danger-text-emphasis: #58151c; |
||||
--bs-light-text-emphasis: #495057; |
||||
--bs-dark-text-emphasis: #495057; |
||||
--bs-primary-bg-subtle: #cfe2ff; |
||||
--bs-secondary-bg-subtle: #e2e3e5; |
||||
--bs-success-bg-subtle: #d1e7dd; |
||||
--bs-info-bg-subtle: #cff4fc; |
||||
--bs-warning-bg-subtle: #fff3cd; |
||||
--bs-danger-bg-subtle: #f8d7da; |
||||
--bs-light-bg-subtle: #fcfcfd; |
||||
--bs-dark-bg-subtle: #ced4da; |
||||
--bs-primary-border-subtle: #9ec5fe; |
||||
--bs-secondary-border-subtle: #c4c8cb; |
||||
--bs-success-border-subtle: #a3cfbb; |
||||
--bs-info-border-subtle: #9eeaf9; |
||||
--bs-warning-border-subtle: #ffe69c; |
||||
--bs-danger-border-subtle: #f1aeb5; |
||||
--bs-light-border-subtle: #e9ecef; |
||||
--bs-dark-border-subtle: #adb5bd; |
||||
--bs-white-rgb: 255, 255, 255; |
||||
--bs-black-rgb: 0, 0, 0; |
||||
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; |
||||
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; |
||||
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0)); |
||||
--bs-body-font-family: var(--bs-font-sans-serif); |
||||
--bs-body-font-size: 1rem; |
||||
--bs-body-font-weight: 400; |
||||
--bs-body-line-height: 1.5; |
||||
--bs-body-color: #212529; |
||||
--bs-body-color-rgb: 33, 37, 41; |
||||
--bs-body-bg: #fff; |
||||
--bs-body-bg-rgb: 255, 255, 255; |
||||
--bs-emphasis-color: #000; |
||||
--bs-emphasis-color-rgb: 0, 0, 0; |
||||
--bs-secondary-color: rgba(33, 37, 41, 0.75); |
||||
--bs-secondary-color-rgb: 33, 37, 41; |
||||
--bs-secondary-bg: #e9ecef; |
||||
--bs-secondary-bg-rgb: 233, 236, 239; |
||||
--bs-tertiary-color: rgba(33, 37, 41, 0.5); |
||||
--bs-tertiary-color-rgb: 33, 37, 41; |
||||
--bs-tertiary-bg: #f8f9fa; |
||||
--bs-tertiary-bg-rgb: 248, 249, 250; |
||||
--bs-heading-color: inherit; |
||||
--bs-link-color: #0d6efd; |
||||
--bs-link-color-rgb: 13, 110, 253; |
||||
--bs-link-decoration: underline; |
||||
--bs-link-hover-color: #0a58ca; |
||||
--bs-link-hover-color-rgb: 10, 88, 202; |
||||
--bs-code-color: #d63384; |
||||
--bs-highlight-bg: #fff3cd; |
||||
--bs-border-width: 1px; |
||||
--bs-border-style: solid; |
||||
--bs-border-color: #dee2e6; |
||||
--bs-border-color-translucent: rgba(0, 0, 0, 0.175); |
||||
--bs-border-radius: 0.375rem; |
||||
--bs-border-radius-sm: 0.25rem; |
||||
--bs-border-radius-lg: 0.5rem; |
||||
--bs-border-radius-xl: 1rem; |
||||
--bs-border-radius-xxl: 2rem; |
||||
--bs-border-radius-2xl: var(--bs-border-radius-xxl); |
||||
--bs-border-radius-pill: 50rem; |
||||
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); |
||||
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); |
||||
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175); |
||||
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075); |
||||
--bs-focus-ring-width: 0.25rem; |
||||
--bs-focus-ring-opacity: 0.25; |
||||
--bs-focus-ring-color: rgba(13, 110, 253, 0.25); |
||||
--bs-form-valid-color: #198754; |
||||
--bs-form-valid-border-color: #198754; |
||||
--bs-form-invalid-color: #dc3545; |
||||
--bs-form-invalid-border-color: #dc3545; |
||||
} |
||||
|
||||
[data-bs-theme=dark] { |
||||
color-scheme: dark; |
||||
--bs-body-color: #adb5bd; |
||||
--bs-body-color-rgb: 173, 181, 189; |
||||
--bs-body-bg: #212529; |
||||
--bs-body-bg-rgb: 33, 37, 41; |
||||
--bs-emphasis-color: #fff; |
||||
--bs-emphasis-color-rgb: 255, 255, 255; |
||||
--bs-secondary-color: rgba(173, 181, 189, 0.75); |
||||
--bs-secondary-color-rgb: 173, 181, 189; |
||||
--bs-secondary-bg: #343a40; |
||||
--bs-secondary-bg-rgb: 52, 58, 64; |
||||
--bs-tertiary-color: rgba(173, 181, 189, 0.5); |
||||
--bs-tertiary-color-rgb: 173, 181, 189; |
||||
--bs-tertiary-bg: #2b3035; |
||||
--bs-tertiary-bg-rgb: 43, 48, 53; |
||||
--bs-primary-text-emphasis: #6ea8fe; |
||||
--bs-secondary-text-emphasis: #a7acb1; |
||||
--bs-success-text-emphasis: #75b798; |
||||
--bs-info-text-emphasis: #6edff6; |
||||
--bs-warning-text-emphasis: #ffda6a; |
||||
--bs-danger-text-emphasis: #ea868f; |
||||
--bs-light-text-emphasis: #f8f9fa; |
||||
--bs-dark-text-emphasis: #dee2e6; |
||||
--bs-primary-bg-subtle: #031633; |
||||
--bs-secondary-bg-subtle: #161719; |
||||
--bs-success-bg-subtle: #051b11; |
||||
--bs-info-bg-subtle: #032830; |
||||
--bs-warning-bg-subtle: #332701; |
||||
--bs-danger-bg-subtle: #2c0b0e; |
||||
--bs-light-bg-subtle: #343a40; |
||||
--bs-dark-bg-subtle: #1a1d20; |
||||
--bs-primary-border-subtle: #084298; |
||||
--bs-secondary-border-subtle: #41464b; |
||||
--bs-success-border-subtle: #0f5132; |
||||
--bs-info-border-subtle: #087990; |
||||
--bs-warning-border-subtle: #997404; |
||||
--bs-danger-border-subtle: #842029; |
||||
--bs-light-border-subtle: #495057; |
||||
--bs-dark-border-subtle: #343a40; |
||||
--bs-heading-color: inherit; |
||||
--bs-link-color: #6ea8fe; |
||||
--bs-link-hover-color: #8bb9fe; |
||||
--bs-link-color-rgb: 110, 168, 254; |
||||
--bs-link-hover-color-rgb: 139, 185, 254; |
||||
--bs-code-color: #e685b5; |
||||
--bs-border-color: #495057; |
||||
--bs-border-color-translucent: rgba(255, 255, 255, 0.15); |
||||
--bs-form-valid-color: #75b798; |
||||
--bs-form-valid-border-color: #75b798; |
||||
--bs-form-invalid-color: #ea868f; |
||||
--bs-form-invalid-border-color: #ea868f; |
||||
} |
||||
|
||||
*, |
||||
*::before, |
||||
*::after { |
||||
box-sizing: border-box; |
||||
} |
||||
|
||||
@media (prefers-reduced-motion: no-preference) { |
||||
:root { |
||||
scroll-behavior: smooth; |
||||
} |
||||
} |
||||
|
||||
body { |
||||
margin: 0; |
||||
font-family: var(--bs-body-font-family); |
||||
font-size: var(--bs-body-font-size); |
||||
font-weight: var(--bs-body-font-weight); |
||||
line-height: var(--bs-body-line-height); |
||||
color: var(--bs-body-color); |
||||
text-align: var(--bs-body-text-align); |
||||
background-color: var(--bs-body-bg); |
||||
-webkit-text-size-adjust: 100%; |
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0); |
||||
} |
||||
|
||||
hr { |
||||
margin: 1rem 0; |
||||
color: inherit; |
||||
border: 0; |
||||
border-top: var(--bs-border-width) solid; |
||||
opacity: 0.25; |
||||
} |
||||
|
||||
h6, h5, h4, h3, h2, h1 { |
||||
margin-top: 0; |
||||
margin-bottom: 0.5rem; |
||||
font-weight: 500; |
||||
line-height: 1.2; |
||||
color: var(--bs-heading-color); |
||||
} |
||||
|
||||
h1 { |
||||
font-size: calc(1.375rem + 1.5vw); |
||||
} |
||||
@media (min-width: 1200px) { |
||||
h1 { |
||||
font-size: 2.5rem; |
||||
} |
||||
} |
||||
|
||||
h2 { |
||||
font-size: calc(1.325rem + 0.9vw); |
||||
} |
||||
@media (min-width: 1200px) { |
||||
h2 { |
||||
font-size: 2rem; |
||||
} |
||||
} |
||||
|
||||
h3 { |
||||
font-size: calc(1.3rem + 0.6vw); |
||||
} |
||||
@media (min-width: 1200px) { |
||||
h3 { |
||||
font-size: 1.75rem; |
||||
} |
||||
} |
||||
|
||||
h4 { |
||||
font-size: calc(1.275rem + 0.3vw); |
||||
} |
||||
@media (min-width: 1200px) { |
||||
h4 { |
||||
font-size: 1.5rem; |
||||
} |
||||
} |
||||
|
||||
h5 { |
||||
font-size: 1.25rem; |
||||
} |
||||
|
||||
h6 { |
||||
font-size: 1rem; |
||||
} |
||||
|
||||
p { |
||||
margin-top: 0; |
||||
margin-bottom: 1rem; |
||||
} |
||||
|
||||
abbr[title] { |
||||
-webkit-text-decoration: underline dotted; |
||||
text-decoration: underline dotted; |
||||
cursor: help; |
||||
-webkit-text-decoration-skip-ink: none; |
||||
text-decoration-skip-ink: none; |
||||
} |
||||
|
||||
address { |
||||
margin-bottom: 1rem; |
||||
font-style: normal; |
||||
line-height: inherit; |
||||
} |
||||
|
||||
ol, |
||||
ul { |
||||
padding-right: 2rem; |
||||
} |
||||
|
||||
ol, |
||||
ul, |
||||
dl { |
||||
margin-top: 0; |
||||
margin-bottom: 1rem; |
||||
} |
||||
|
||||
ol ol, |
||||
ul ul, |
||||
ol ul, |
||||
ul ol { |
||||
margin-bottom: 0; |
||||
} |
||||
|
||||
dt { |
||||
font-weight: 700; |
||||
} |
||||
|
||||
dd { |
||||
margin-bottom: 0.5rem; |
||||
margin-right: 0; |
||||
} |
||||
|
||||
blockquote { |
||||
margin: 0 0 1rem; |
||||
} |
||||
|
||||
b, |
||||
strong { |
||||
font-weight: bolder; |
||||
} |
||||
|
||||
small { |
||||
font-size: 0.875em; |
||||
} |
||||
|
||||
mark { |
||||
padding: 0.1875em; |
||||
background-color: var(--bs-highlight-bg); |
||||
} |
||||
|
||||
sub, |
||||
sup { |
||||
position: relative; |
||||
font-size: 0.75em; |
||||
line-height: 0; |
||||
vertical-align: baseline; |
||||
} |
||||
|
||||
sub { |
||||
bottom: -0.25em; |
||||
} |
||||
|
||||
sup { |
||||
top: -0.5em; |
||||
} |
||||
|
||||
a { |
||||
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1)); |
||||
text-decoration: underline; |
||||
} |
||||
a:hover { |
||||
--bs-link-color-rgb: var(--bs-link-hover-color-rgb); |
||||
} |
||||
|
||||
a:not([href]):not([class]), a:not([href]):not([class]):hover { |
||||
color: inherit; |
||||
text-decoration: none; |
||||
} |
||||
|
||||
pre, |
||||
code, |
||||
kbd, |
||||
samp { |
||||
font-family: var(--bs-font-monospace); |
||||
font-size: 1em; |
||||
} |
||||
|
||||
pre { |
||||
display: block; |
||||
margin-top: 0; |
||||
margin-bottom: 1rem; |
||||
overflow: auto; |
||||
font-size: 0.875em; |
||||
} |
||||
pre code { |
||||
font-size: inherit; |
||||
color: inherit; |
||||
word-break: normal; |
||||
} |
||||
|
||||
code { |
||||
font-size: 0.875em; |
||||
color: var(--bs-code-color); |
||||
word-wrap: break-word; |
||||
} |
||||
a > code { |
||||
color: inherit; |
||||
} |
||||
|
||||
kbd { |
||||
padding: 0.1875rem 0.375rem; |
||||
font-size: 0.875em; |
||||
color: var(--bs-body-bg); |
||||
background-color: var(--bs-body-color); |
||||
border-radius: 0.25rem; |
||||
} |
||||
kbd kbd { |
||||
padding: 0; |
||||
font-size: 1em; |
||||
} |
||||
|
||||
figure { |
||||
margin: 0 0 1rem; |
||||
} |
||||
|
||||
img, |
||||
svg { |
||||
vertical-align: middle; |
||||
} |
||||
|
||||
table { |
||||
caption-side: bottom; |
||||
border-collapse: collapse; |
||||
} |
||||
|
||||
caption { |
||||
padding-top: 0.5rem; |
||||
padding-bottom: 0.5rem; |
||||
color: var(--bs-secondary-color); |
||||
text-align: right; |
||||
} |
||||
|
||||
th { |
||||
text-align: inherit; |
||||
text-align: -webkit-match-parent; |
||||
} |
||||
|
||||
thead, |
||||
tbody, |
||||
tfoot, |
||||
tr, |
||||
td, |
||||
th { |
||||
border-color: inherit; |
||||
border-style: solid; |
||||
border-width: 0; |
||||
} |
||||
|
||||
label { |
||||
display: inline-block; |
||||
} |
||||
|
||||
button { |
||||
border-radius: 0; |
||||
} |
||||
|
||||
button:focus:not(:focus-visible) { |
||||
outline: 0; |
||||
} |
||||
|
||||
input, |
||||
button, |
||||
select, |
||||
optgroup, |
||||
textarea { |
||||
margin: 0; |
||||
font-family: inherit; |
||||
font-size: inherit; |
||||
line-height: inherit; |
||||
} |
||||
|
||||
button, |
||||
select { |
||||
text-transform: none; |
||||
} |
||||
|
||||
[role=button] { |
||||
cursor: pointer; |
||||
} |
||||
|
||||
select { |
||||
word-wrap: normal; |
||||
} |
||||
select:disabled { |
||||
opacity: 1; |
||||
} |
||||
|
||||
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator { |
||||
display: none !important; |
||||
} |
||||
|
||||
button, |
||||
[type=button], |
||||
[type=reset], |
||||
[type=submit] { |
||||
-webkit-appearance: button; |
||||
} |
||||
button:not(:disabled), |
||||
[type=button]:not(:disabled), |
||||
[type=reset]:not(:disabled), |
||||
[type=submit]:not(:disabled) { |
||||
cursor: pointer; |
||||
} |
||||
|
||||
::-moz-focus-inner { |
||||
padding: 0; |
||||
border-style: none; |
||||
} |
||||
|
||||
textarea { |
||||
resize: vertical; |
||||
} |
||||
|
||||
fieldset { |
||||
min-width: 0; |
||||
padding: 0; |
||||
margin: 0; |
||||
border: 0; |
||||
} |
||||
|
||||
legend { |
||||
float: right; |
||||
width: 100%; |
||||
padding: 0; |
||||
margin-bottom: 0.5rem; |
||||
font-size: calc(1.275rem + 0.3vw); |
||||
line-height: inherit; |
||||
} |
||||
@media (min-width: 1200px) { |
||||
legend { |
||||
font-size: 1.5rem; |
||||
} |
||||
} |
||||
legend + * { |
||||
clear: right; |
||||
} |
||||
|
||||
::-webkit-datetime-edit-fields-wrapper, |
||||
::-webkit-datetime-edit-text, |
||||
::-webkit-datetime-edit-minute, |
||||
::-webkit-datetime-edit-hour-field, |
||||
::-webkit-datetime-edit-day-field, |
||||
::-webkit-datetime-edit-month-field, |
||||
::-webkit-datetime-edit-year-field { |
||||
padding: 0; |
||||
} |
||||
|
||||
::-webkit-inner-spin-button { |
||||
height: auto; |
||||
} |
||||
|
||||
[type=search] { |
||||
outline-offset: -2px; |
||||
-webkit-appearance: textfield; |
||||
} |
||||
|
||||
[type="tel"], |
||||
[type="url"], |
||||
[type="email"], |
||||
[type="number"] { |
||||
direction: ltr; |
||||
} |
||||
::-webkit-search-decoration { |
||||
-webkit-appearance: none; |
||||
} |
||||
|
||||
::-webkit-color-swatch-wrapper { |
||||
padding: 0; |
||||
} |
||||
|
||||
::-webkit-file-upload-button { |
||||
font: inherit; |
||||
-webkit-appearance: button; |
||||
} |
||||
|
||||
::file-selector-button { |
||||
font: inherit; |
||||
-webkit-appearance: button; |
||||
} |
||||
|
||||
output { |
||||
display: inline-block; |
||||
} |
||||
|
||||
iframe { |
||||
border: 0; |
||||
} |
||||
|
||||
summary { |
||||
display: list-item; |
||||
cursor: pointer; |
||||
} |
||||
|
||||
progress { |
||||
vertical-align: baseline; |
||||
} |
||||
|
||||
[hidden] { |
||||
display: none !important; |
||||
} |
||||
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */ |
After Width: | Height: | Size: 34 KiB |
After Width: | Height: | Size: 9.6 KiB |
After Width: | Height: | Size: 30 KiB |
After Width: | Height: | Size: 13 KiB |
After Width: | Height: | Size: 775 B |
After Width: | Height: | Size: 1.9 KiB |
After Width: | Height: | Size: 34 KiB |