Browse Source

Initial commit; API, a bit of front-end

master
Unbewohnte 2 years ago
commit
bbcb76d05e
  1. 3
      .gitignore
  2. 12
      Makefile
  3. 6
      README.md
  4. 1
      deps/sqlite
  5. 78
      pages/base.html
  6. 15
      pages/index.html
  7. 9
      pages/list.html
  8. 58
      pages/login.html
  9. 60
      pages/register.html
  10. 569
      scripts/auth.js
  11. 69
      src/conf/conf.go
  12. 91
      src/db/db.go
  13. 79
      src/db/db_test.go
  14. 230
      src/db/todo.go
  15. 79
      src/db/user.go
  16. 25
      src/encryption/encryption.go
  17. 26
      src/go.mod
  18. 65
      src/go.sum
  19. 1
      src/list/list.go
  20. 42
      src/logger/logger.go
  21. 65
      src/main.go
  22. 426
      src/server/api.go
  23. 130
      src/server/api_test.go
  24. 122
      src/server/auth.go
  25. 21
      src/server/page.go
  26. 166
      src/server/server.go
  27. 33
      src/server/validation.go
  28. 4085
      static/bootstrap/css/bootstrap-grid.css
  29. 1
      static/bootstrap/css/bootstrap-grid.css.map
  30. 6
      static/bootstrap/css/bootstrap-grid.min.css
  31. 1
      static/bootstrap/css/bootstrap-grid.min.css.map
  32. 4084
      static/bootstrap/css/bootstrap-grid.rtl.css
  33. 1
      static/bootstrap/css/bootstrap-grid.rtl.css.map
  34. 6
      static/bootstrap/css/bootstrap-grid.rtl.min.css
  35. 1
      static/bootstrap/css/bootstrap-grid.rtl.min.css.map
  36. 593
      static/bootstrap/css/bootstrap-reboot.css
  37. 1
      static/bootstrap/css/bootstrap-reboot.css.map
  38. 6
      static/bootstrap/css/bootstrap-reboot.min.css
  39. 1
      static/bootstrap/css/bootstrap-reboot.min.css.map
  40. 590
      static/bootstrap/css/bootstrap-reboot.rtl.css
  41. 1
      static/bootstrap/css/bootstrap-reboot.rtl.css.map
  42. 6
      static/bootstrap/css/bootstrap-reboot.rtl.min.css
  43. 1
      static/bootstrap/css/bootstrap-reboot.rtl.min.css.map
  44. 5399
      static/bootstrap/css/bootstrap-utilities.css
  45. 1
      static/bootstrap/css/bootstrap-utilities.css.map
  46. 6
      static/bootstrap/css/bootstrap-utilities.min.css
  47. 1
      static/bootstrap/css/bootstrap-utilities.min.css.map
  48. 5390
      static/bootstrap/css/bootstrap-utilities.rtl.css
  49. 1
      static/bootstrap/css/bootstrap-utilities.rtl.css.map
  50. 6
      static/bootstrap/css/bootstrap-utilities.rtl.min.css
  51. 1
      static/bootstrap/css/bootstrap-utilities.rtl.min.css.map
  52. 12071
      static/bootstrap/css/bootstrap.css
  53. 1
      static/bootstrap/css/bootstrap.css.map
  54. 6
      static/bootstrap/css/bootstrap.min.css
  55. 1
      static/bootstrap/css/bootstrap.min.css.map
  56. 12035
      static/bootstrap/css/bootstrap.rtl.css
  57. 1
      static/bootstrap/css/bootstrap.rtl.css.map
  58. 6
      static/bootstrap/css/bootstrap.rtl.min.css
  59. 1
      static/bootstrap/css/bootstrap.rtl.min.css.map
  60. 6306
      static/bootstrap/js/bootstrap.bundle.js
  61. 1
      static/bootstrap/js/bootstrap.bundle.js.map
  62. 7
      static/bootstrap/js/bootstrap.bundle.min.js
  63. 1
      static/bootstrap/js/bootstrap.bundle.min.js.map
  64. 4439
      static/bootstrap/js/bootstrap.esm.js
  65. 1
      static/bootstrap/js/bootstrap.esm.js.map
  66. 7
      static/bootstrap/js/bootstrap.esm.min.js
  67. 1
      static/bootstrap/js/bootstrap.esm.min.js.map
  68. 4486
      static/bootstrap/js/bootstrap.js
  69. 1
      static/bootstrap/js/bootstrap.js.map
  70. 7
      static/bootstrap/js/bootstrap.min.js
  71. 1
      static/bootstrap/js/bootstrap.min.js.map

3
.gitignore vendored

@ -0,0 +1,3 @@
bin/
TODO
dela.db

12
Makefile

@ -0,0 +1,12 @@
all:
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
clean:
rm -rf bin

6
README.md

@ -0,0 +1,6 @@
# dela - daily events list application (pun интедед)
## License
AGPLv3

1
deps/sqlite vendored

@ -0,0 +1 @@
Subproject commit 2a7e293371bb00c11907440f3a0e3aa517b3934c

78
pages/base.html

@ -0,0 +1,78 @@
{{ 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="stylesheet" href="/static/bootstrap/css/bootstrap.min.css">
<script src="/static/bootstrap/js/bootstrap.min.js"></script>
<!-- <link rel="stylesheet" href="/static/stylesheet.css"> -->
<link rel="shortcut icon" href="/static/images/favicon.png" type="image/x-icon">
</head>
<body class="d-flex flex-column h-100">
<header class="d-flex flex-wrap align-items-center justify-content-center justify-content-md-between 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">
<svg class="bi" width="40" height="32" role="img" aria-label="Bootstrap"><use xlink:href="#bootstrap"></use></svg>
</a>
</div>
<!-- <ul class="nav col-12 col-md-auto mb-2 justify-content-center mb-md-0">
<li><a href="/" class="nav-link px-2">Home</a></li>
<li><a href="/about" class="nav-link px-2">About</a></li>
<li><span href="#" class="nav-link px-2" id="logged-in-as"></span></li>
</ul> -->
<div class="col-md-3 text-end" id="bar-auth">
<a href="/login" class="btn btn-outline-primary me-2">Log in</a>
<a href="/register" class="btn btn-primary">Register</a>
</div>
</header>
<div style="margin: auto;
margin-top: 5ch;
margin-bottom: 10ch;
max-width: 120ch;">
{{ template "content" . }}
</div>
</body>
</html>
<script src="/scripts/auth.js"></script>
<script>
window.onload = async () => {
let username = getUsername();
let password = getUserPassword();
if (username == null | username == "") {
return;
}
// Check if auth info is indeed valid
let response = await fetch("/api/user", {
method: "GET",
headers: {
"EncryptedBase64": "false",
"Auth": username + "<-->" + password
},
});
if (response.ok) {
let barAuth = document.getElementById("bar-auth");
barAuth.innerHTML = "<b>" + username + "</b>" + " | ";
barAuth.innerHTML += '<button id="log-out-btn" class="btn btn-primary" type="button">Log out</button>';
document.getElementById("log-out-btn").addEventListener("click", (event) => {
// Log out
forgetAuthInfo();
window.location.replace("/");
});
}
}
</script>
{{ end }}

15
pages/index.html

@ -0,0 +1,15 @@
{{ template "base" . }}
{{ define "content" }}
<div class="cover-container d-flex w-100 h-100 p-3 mx-auto flex-column">
<main class="px-3">
<h1>Dela.</h1>
<p class="lead">A free and open-source web TODO list</p>
<p class="lead">
<a href="/login" class="btn btn-lg btn-primary">Login</a>
<a href="/register" class="btn btn-lg btn-primary">Register</a>
</p>
</main>
</div>
{{ end }}

9
pages/list.html

@ -0,0 +1,9 @@
{{ template "base" . }}
{{ define "content" }}
<script>
</script>
{{ end }}

58
pages/login.html

@ -0,0 +1,58 @@
{{ template "base" . }}
{{ define "content" }}
<h3>Log in</h3>
<form name="loginForm" onsubmit="return false;">
<p>
<label for="username" class="form-label">Username</label> <br>
<input type="text" name="username" minlength="3" required>
</p>
<p>
<label for="password" class="form-label">Password</label> <br>
<input type="password" name="password" minlength="3" required>
</p>
<p><span id="error_message" class="text-danger"></span></p>
<p>
<input type="submit" value="Log in" class="btn btn-primary" onmouseup="logIn()">
</p>
</form>
<script>
async function logIn() {
let loginForm = document.forms["loginForm"];
let username = String(loginForm.elements["username"].value).trim();
if (username.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 fetch("/api/user", {
method: "GET",
headers: {
"EncryptedBase64": "false",
"Auth": username + "<-->" + password
},
});
if (response.ok) {
rememberAuthInfo(username, password);
window.location.replace("/");
} else {
document.getElementById("error_message").innerText = await response.text();
}
}
</script>
{{ end }}

60
pages/register.html

@ -0,0 +1,60 @@
{{ template "base" . }}
{{ define "content" }}
<h3>Register</h3>
<form name="registerForm" onsubmit="return false;">
<p>
<label for="username" class="form-label">Username</label> <br>
<input type="text" name="username" minlength="3" required>
</p>
<p>
<label for="password" class="form-label">Password</label> <br>
<input type="password" name="password" minlength="3" required>
</p>
<p><span id="error_message" class="text-danger"></span></p>
<p>
<input type="submit" value="Register" class="btn btn-primary" onmouseup="register();">
</p>
</form>
<script>
async function register() {
let registerForm = document.forms["registerForm"];
let username = String(registerForm.elements["username"].value).trim();
if (username.length < 3) {
return;
}
let password = String(registerForm.elements["password"].value);
if (password.length < 3) {
return;
}
let passwordSHA256 = sha256(password);
let postData = {
username: username,
password: passwordSHA256,
};
let response = await fetch("/api/user", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(postData),
});
if (response.ok) {
rememberAuthInfo(postData.username, postData.password);
window.location.replace("/");
} else {
document.getElementById("error_message").innerText = await response.text();
}
};
</script>
{{ end }}

569
scripts/auth.js

@ -0,0 +1,569 @@
/*
Copyright (c) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte)
*/
const AuthHeaderKey = "Auth";
const AuthSeparator = "<->" // username<->password
const authStorageUsername = "username";
const authStoragePassword = "password";
function encodeStringBase64(string) {
return btoa(
encodeURIComponent(string).replace(/%([0-9A-F]{2})/g, function to_bytes(match, p) {
return String.fromCharCode('0x' + p);
})
);
}
// Returns properly constructed string containing authentication information
function authString(login, password) {
return String(login) + String(AuthSeparator) + String(password);
}
// Returns properly constructed string containing authentication information, encodes afterwards
function authStringEncoded(login, password) {
return encodeStringBase64(authString(login, password));
}
// Saves auth information to local storage
function rememberAuthInfo(username, password) {
localStorage.setItem(authStorageUsername, username);
localStorage.setItem(authStoragePassword, password);
}
// Retrieves user's login from local storage
function getUsername() {
return localStorage.getItem(authStorageUsername);
}
// Retrieves user's password from local storage
function getUserPassword() {
return localStorage.getItem(authStoragePassword);
}
// Removes all auth information from local storage
function forgetAuthInfo() {
localStorage.removeItem(authStorageUsername);
localStorage.removeItem(authStoragePassword);
}
/**
* [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;
});
}
}
})();

69
src/conf/conf.go

@ -0,0 +1,69 @@
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"`
}
// 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
}

91
src/db/db.go

@ -0,0 +1,91 @@
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(
username TEXT PRIMARY KEY 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_username TEXT NOT NULL,
FOREIGN KEY(owner_username) REFERENCES users(username))`,
)
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,
due_unix INTEGER,
owner_username TEXT NOT NULL,
FOREIGN KEY(group_id) REFERENCES todo_groups(id),
FOREIGN KEY(owner_username) REFERENCES users(username))`,
)
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
}

79
src/db/db_test.go

@ -0,0 +1,79 @@
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{
Username: "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.Username)
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,
OwnerUsername: user.Username,
}
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,
OwnerUsername: user.Username,
}
err = db.CreateTodo(todo)
if err != nil {
t.Fatalf("couldn't create a new TODO: %s", err)
}
// Now deletion
err = db.DeleteUserClean(user.Username)
if err != nil {
t.Fatalf("couldn't cleanly delete user with all TODOs: %s", err)
}
}

230
src/db/todo.go

@ -0,0 +1,230 @@
package db
import "database/sql"
// Todo group structure
type TodoGroup struct {
ID uint64
Name string
TimeCreatedUnix uint64
OwnerUsername string
}
// Todo structure
type Todo struct {
ID uint64
GroupID uint64
Text string
TimeCreatedUnix uint64
DueUnix uint64
OwnerUsername string
}
// Creates a new TODO group in the database
func (db *DB) CreateTodoGroup(group TodoGroup) error {
_, err := db.Exec(
"INSERT INTO todo_groups(name, time_created_unix, owner_username) VALUES(?, ?, ?)",
group.Name,
group.TimeCreatedUnix,
group.OwnerUsername,
)
return err
}
func scanTodoGroup(rows *sql.Rows) (*TodoGroup, error) {
var newTodoGroup TodoGroup
err := rows.Scan(
&newTodoGroup.ID,
&newTodoGroup.Name,
&newTodoGroup.TimeCreatedUnix,
&newTodoGroup.OwnerUsername,
)
if err != nil {
return nil, err
}
return &newTodoGroup, nil
}
// Retrieves a TODO group with provided ID from the database
func (db *DB) GetTodoGroup(id uint64) (*TodoGroup, error) {
rows, err := db.Query(
"SELECT * FROM todo_groups WHERE id=?",
id,
)
if err != nil {
return nil, err
}
defer rows.Close()
rows.Next()
todoGroup, err := scanTodoGroup(rows)
if err != nil {
return nil, err
}
return todoGroup, nil
}
// Deletes information about a TODO group of given ID from the database
func (db *DB) DeleteTodoGroup(id uint64) error {
_, err := db.Exec(
"DELETE FROM todo_groups WHERE id=?",
id,
)
return err
}
// Updates TODO group's name
func (db *DB) UpdateTodoGroup(groupID uint64, updatedGroup TodoGroup) error {
_, err := db.Exec(
"UPDATE todo_groups SET name=? WHERE id=?",
updatedGroup.Name,
groupID,
)
return err
}
func scanTodo(rows *sql.Rows) (*Todo, error) {
var newTodo Todo
err := rows.Scan(
&newTodo.ID,
&newTodo.GroupID,
&newTodo.Text,
&newTodo.DueUnix,
&newTodo.OwnerUsername,
)
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
}
// Creates a new TODO in the database
func (db *DB) CreateTodo(todo Todo) error {
_, err := db.Exec(
"INSERT INTO todos(group_id, text, due_unix, owner_username) VALUES(?, ?, ?, ?)",
todo.GroupID,
todo.Text,
todo.DueUnix,
todo.OwnerUsername,
)
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 and group id
func (db *DB) UpdateTodo(todoID uint64, updatedTodo Todo) error {
_, err := db.Exec(
"UPDATE todos SET group_id=?, due_unix=?, text=? WHERE id=?",
updatedTodo.GroupID,
updatedTodo.DueUnix,
updatedTodo.Text,
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
}

79
src/db/user.go

@ -0,0 +1,79 @@
package db
import "database/sql"
// User structure
type User struct {
Username string
Password string
TimeCreatedUnix uint64
}
func scanUser(rows *sql.Rows) (*User, error) {
rows.Next()
var user User
err := rows.Scan(&user.Username, &user.Password, &user.TimeCreatedUnix)
if err != nil {
return nil, err
}
return &user, nil
}
// Searches for user with username and returns it
func (db *DB) GetUser(username string) (*User, error) {
rows, err := db.Query("SELECT * FROM users WHERE username=?", username)
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(username, password, time_created_unix) VALUES(?, ?, ?)",
newUser.Username,
newUser.Password,
newUser.TimeCreatedUnix,
)
return err
}
// Deletes user with given username
func (db *DB) DeleteUser(username string) error {
_, err := db.Exec(
"DELETE FROM users WHERE username=?",
username,
)
return err
}
// Deletes a user and all his TODOs (with groups) as well
func (db *DB) DeleteUserClean(username string) error {
err := db.DeleteAllUserTodoGroups(username)
if err != nil {
return err
}
err = db.DeleteAllUserTodos(username)
if err != nil {
return err
}
err = db.DeleteUser(username)
if err != nil {
return err
}
return nil
}

25
src/encryption/encryption.go

@ -0,0 +1,25 @@
package encryption
import (
"crypto/sha256"
"encoding/base64"
"fmt"
)
// Encodes given string via Base64
func EncodeString(str string) string {
return base64.StdEncoding.EncodeToString([]byte(str))
}
// Decodes given string via Base64
func DecodeString(encodedStr string) string {
decodedBytes, _ := base64.StdEncoding.DecodeString(encodedStr)
return string(decodedBytes)
}
// Returns HEX string of SHA256'd data
func SHA256Hex(data []byte) string {
hash := sha256.New()
hash.Write(data)
return fmt.Sprintf("%x", hash.Sum(nil))
}

26
src/go.mod

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

65
src/go.sum

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

1
src/list/list.go

@ -0,0 +1 @@
package list

42
src/logger/logger.go

@ -0,0 +1,42 @@
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...)
}

65
src/main.go

@ -0,0 +1,65 @@
package main
import (
"Unbewohnte/dela/conf"
"Unbewohnte/dela/logger"
"Unbewohnte/dela/server"
"os"
"path/filepath"
)
const ConfName string = "conf.json"
var (
WDir string
Conf conf.Conf
)
func init() {
// 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
}
// Check if database exists and create it otherwise
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
}
}

426
src/server/api.go

@ -0,0 +1,426 @@
package server
import (
"Unbewohnte/dela/db"
"Unbewohnte/dela/logger"
"encoding/json"
"io"
"net/http"
"time"
)
func (s *Server) UserEndpoint(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodDelete:
// Delete an existing user
defer req.Body.Close()
// Read body
body, err := io.ReadAll(req.Body)
if err != nil {
logger.Warning("[Server] Failed to read request body to delete a user: %s", err)
http.Error(w, "Failed to read body", http.StatusInternalServerError)
return
}
// Unmarshal JSON
var newUser db.User
err = json.Unmarshal(body, &newUser)
if err != nil {
logger.Warning("[Server] Received invalid user JSON for deletion: %s", err)
http.Error(w, "Invalid user JSON", http.StatusBadRequest)
return
}
username := GetUsernameFromAuth(req)
// Check if auth data is valid
if !IsRequestAuthValid(req, s.db) {
logger.Warning("[Server] %s failed to authenticate as %s", req.RemoteAddr, username)
http.Error(w, "Invalid user auth data", http.StatusBadRequest)
return
}
// It is, indeed, a user
// Delete with all TODOs
err = s.db.DeleteUserClean(username)
if err != nil {
logger.Error("[Server] Failed to delete %s: %s", username, err)
http.Error(w, "Failed to delete user or TODO contents", http.StatusInternalServerError)
return
}
// Success!
w.WriteHeader(http.StatusOK)
case http.MethodPost:
// Create a new user
defer req.Body.Close()
// Read body
body, err := io.ReadAll(req.Body)
if err != nil {
logger.Warning("[Server] Failed to read request body to create a new user: %s", err)
http.Error(w, "Failed to read body", http.StatusInternalServerError)
return
}
// Unmarshal JSON
var newUser db.User
err = json.Unmarshal(body, &newUser)
if err != nil {
logger.Warning("[Server] Received invalid user JSON for creation: %s", err)
http.Error(w, "Invalid user JSON", http.StatusBadRequest)
return
}
// Check for validity
valid, reason := IsUserValid(newUser)
if !valid {
logger.Info("[Server] Rejected creating %s for reason: %s", newUser.Username, reason)
http.Error(w, "Invalid user data: "+reason, http.StatusBadRequest)
return
}
// Add user to the database
newUser.TimeCreatedUnix = uint64(time.Now().Unix())
err = s.db.CreateUser(newUser)
if err != nil {
http.Error(w, "User already exists", http.StatusInternalServerError)
return
}
// Success!
w.WriteHeader(http.StatusOK)
logger.Info("[Server] Created a new user \"%s\"", newUser.Username)
case http.MethodGet:
// Check if user information is valid
if !IsRequestAuthValid(req, s.db) {
http.Error(w, "Invalid user auth data", http.StatusForbidden)
return
}
w.WriteHeader(http.StatusOK)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) TodoEndpoint(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodDelete:
// Delete an existing 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 possibly delete a TODO: %s", err)
http.Error(w, "Failed to read body", http.StatusInternalServerError)
return
}
// Unmarshal JSON
var todo db.Todo
err = json.Unmarshal(body, &todo)
if err != nil {
logger.Warning("[Server] Received invalid TODO JSON for deletion: %s", err)
http.Error(w, "Invalid TODO JSON", http.StatusBadRequest)
return
}
// Check if given user actually owns this TODO
if !IsRequestAuthValid(req, s.db) {
http.Error(w, "Invalid user auth data", http.StatusForbidden)
return
}
if !DoesUserOwnTodo(GetUsernameFromAuth(req), todo.ID, s.db) {
http.Error(w, "You don't own this TODO", http.StatusForbidden)
return
}
// Now delete
err = s.db.DeleteTodo(todo.ID)
if err != nil {
logger.Error("[Server] Failed to delete %s's TODO: %s", GetUsernameFromAuth(req), err)
http.Error(w, "Failed to delete TODO", http.StatusInternalServerError)
return
}
// Success!
w.WriteHeader(http.StatusOK)
case http.MethodPost:
// Create a new TODO
defer req.Body.Close()
// Read body
body, err := io.ReadAll(req.Body)
if err != nil {
logger.Warning("[Server] Failed to read request body to create a new TODO: %s", err)
http.Error(w, "Failed to read body", http.StatusInternalServerError)
return
}
// Unmarshal JSON
var newTodo db.Todo
err = json.Unmarshal(body, &newTodo)
if err != nil {
logger.Warning("[Server] Received invalid TODO JSON for creation: %s", err)
http.Error(w, "Invalid TODO JSON", http.StatusBadRequest)
return
}
// Check for authentication problems
if !IsRequestAuthValid(req, s.db) {
http.Error(w, "Invalid user auth data", http.StatusForbidden)
return
}
// Add TODO to the database
newTodo.OwnerUsername = GetUsernameFromAuth(req)
newTodo.TimeCreatedUnix = uint64(time.Now().Unix())
err = s.db.CreateTodo(newTodo)
if err != nil {
http.Error(w, "Failed to create TODO", http.StatusInternalServerError)
return
}
// Success!
w.WriteHeader(http.StatusOK)
logger.Info("[Server] Created a new TODO for %s", newTodo.OwnerUsername)
case http.MethodGet:
// Retrieve TODO information
// Check authentication information
if !IsRequestAuthValid(req, s.db) {
http.Error(w, "Invalid user auth data", http.StatusForbidden)
return
}
todoID, err := GetTodoIDFromReq(req)
if err != nil {
http.Error(w, "Invalid TODO ID", http.StatusBadRequest)
return
}
if !DoesUserOwnTodo(GetUsernameFromAuth(req), todoID, s.db) {
http.Error(w, "You don't own this TODO", http.StatusForbidden)
return
}
// Get TODO
todo, err := s.db.GetTodo(todoID)
if err != nil {
http.Error(w, "Failed to get TODO", http.StatusInternalServerError)
return
}
// Marshal to JSON
todoBytes, err := json.Marshal(&todo)
if err != nil {
http.Error(w, "Failed to marhsal TODO JSON", http.StatusInternalServerError)
return
}
// Send out
w.Header().Add("Content-Type", "application/json")
w.Write(todoBytes)
case http.MethodPatch:
// Change TODO due date and text
// Check authentication information
if !IsRequestAuthValid(req, s.db) {
http.Error(w, "Invalid user auth data", http.StatusForbidden)
return
}
// Read body
body, err := io.ReadAll(req.Body)
if err != nil {
logger.Warning("[Server] Failed to read request body to possibly update a TODO: %s", err)
http.Error(w, "Failed to read body", http.StatusInternalServerError)
return
}
// Unmarshal JSON
var todo db.Todo
err = json.Unmarshal(body, &todo)
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
}
// TODO
err = s.db.UpdateTodo(todo.ID, todo)
if err != nil {
logger.Warning("[Server] Failed to update TODO: %s", err)
http.Error(w, "Failed to update", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) TodoGroupEndpoint(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodDelete:
// Delete an existing group
defer req.Body.Close()
// Read body
body, err := io.ReadAll(req.Body)
if err != nil {
logger.Warning("[Server] Failed to read request body to possibly delete a TODO group: %s", err)
http.Error(w, "Failed to read body", http.StatusInternalServerError)
return
}
// Unmarshal JSON
var group db.TodoGroup
err = json.Unmarshal(body, &group)
if err != nil {
logger.Warning("[Server] Received invalid TODO group JSON for deletion: %s", err)
http.Error(w, "Invalid TODO group JSON", http.StatusBadRequest)
return
}
// Check if given user actually owns this group
if !IsRequestAuthValid(req, s.db) {
http.Error(w, "Invalid user auth data", http.StatusForbidden)
return
}
if !DoesUserOwnTodoGroup(GetUsernameFromAuth(req), group.ID, s.db) {
http.Error(w, "You don't own this group", http.StatusForbidden)
return
}
// Now delete
err = s.db.DeleteTodoGroup(group.ID)
if err != nil {
logger.Error("[Server] Failed to delete %s's TODO group: %s", GetUsernameFromAuth(req), err)
http.Error(w, "Failed to delete TODO group", http.StatusInternalServerError)
return
}
// Success!
w.WriteHeader(http.StatusOK)
case http.MethodPost:
// Create a new TODO group
defer req.Body.Close()
// Read body
body, err := io.ReadAll(req.Body)
if err != nil {
logger.Warning("[Server] Failed to read request body to create a new TODO group: %s", err)
http.Error(w, "Failed to read body", http.StatusInternalServerError)
return
}
// Unmarshal JSON
var newGroup db.TodoGroup
err = json.Unmarshal(body, &newGroup)
if err != nil {
logger.Warning("[Server] Received invalid TODO group JSON for creation: %s", err)
http.Error(w, "Invalid TODO group JSON", http.StatusBadRequest)
return
}
// Check for authentication problems
if !IsRequestAuthValid(req, s.db) {
http.Error(w, "Invalid user auth data", http.StatusForbidden)
return
}
// Add group to the database
newGroup.OwnerUsername = GetUsernameFromAuth(req)
newGroup.TimeCreatedUnix = uint64(time.Now().Unix())
err = s.db.CreateTodoGroup(newGroup)
if err != nil {
http.Error(w, "Failed to create TODO group", http.StatusInternalServerError)
return
}
// Success!
w.WriteHeader(http.StatusOK)
logger.Info("[Server] Created a new TODO group for %s", newGroup.OwnerUsername)
case http.MethodGet:
// Retrieve todo group
// Check authentication information
if !IsRequestAuthValid(req, s.db) {
http.Error(w, "Invalid user auth data", http.StatusForbidden)
return
}
groupID, err := GetTodoIDFromReq(req)
if err != nil {
http.Error(w, "Invalid group ID", http.StatusBadRequest)
return
}
if !DoesUserOwnTodoGroup(GetUsernameFromAuth(req), groupID, s.db) {
http.Error(w, "You don't own this group", http.StatusForbidden)
return
}
// Get group
group, err := s.db.GetTodoGroup(groupID)
if err != nil {
http.Error(w, "Failed to get TODO group", http.StatusInternalServerError)
return
}
// Marshal to JSON
groupBytes, err := json.Marshal(&group)
if err != nil {
http.Error(w, "Failed to marhsal TODO group JSON", http.StatusInternalServerError)
return
}
// Send out
w.Header().Add("Content-Type", "application/json")
w.Write(groupBytes)
case http.MethodPatch:
// Check authentication information
if !IsRequestAuthValid(req, s.db) {
http.Error(w, "Invalid user auth data", http.StatusForbidden)
return
}
// Read body
body, err := io.ReadAll(req.Body)
if err != nil {
logger.Warning("[Server] Failed to read request body to possibly update a TODO group: %s", err)
http.Error(w, "Failed to read body", http.StatusInternalServerError)
return
}
// Unmarshal JSON
var group db.TodoGroup
err = json.Unmarshal(body, &group)
if err != nil {
logger.Warning("[Server] Received invalid TODO group JSON in order to update: %s", err)
http.Error(w, "Invalid group JSON", http.StatusBadRequest)
return
}
// TODO
err = s.db.UpdateTodoGroup(group.ID, group)
if err != nil {
logger.Warning("[Server] Failed to update TODO group: %s", err)
http.Error(w, "Failed to update", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}

130
src/server/api_test.go

@ -0,0 +1,130 @@
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{
Username: "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 group and a TODO
newGroup := db.TodoGroup{
Name: "group1",
TimeCreatedUnix: 13524534,
OwnerUsername: newUser.Username,
}
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.Username, 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: newGroup.ID,
Text: "Do the dishes",
TimeCreatedUnix: uint64(time.Now().UnixMicro()),
DueUnix: uint64(time.Now().Add(time.Hour * 5).UnixMicro()),
OwnerUsername: newUser.Username,
}
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)
}
req.Header.Add(RequestHeaderAuthKey, fmt.Sprintf("%s%s%s", newUser.Username, RequestHeaderAuthSeparator, newUser.Password))
req.Header.Add(RequestHeaderEncodedB64, "false")
resp, err = http.DefaultClient.Do(req)
if err != nil {
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()
}

122
src/server/auth.go

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

21
src/server/page.go

@ -0,0 +1,21 @@
package server
import (
"Unbewohnte/dela/logger"
"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 {
logger.Error("Failed to parse page files (pagename is \"%s\"): %s", pageName, err)
return nil, err
}
return page, nil
}

166
src/server/server.go

@ -0,0 +1,166 @@
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) {
// Ignore favicon request. It's not an .ico
if req.URL.Path == "favicon.ico" {
return
}
switch req.URL.Path {
case "/":
requestedPage, err := getPage(
filepath.Join(server.config.BaseContentDir, PagesDirName), "base.html", "index.html",
)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
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 {
// Redirect to the index
index, err := getPage(
filepath.Join(server.config.BaseContentDir, PagesDirName),
"base.html",
req.URL.Path[1:]+".html",
)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
index.ExecuteTemplate(w, "index.html", nil)
}
}
})
mux.HandleFunc("/api/user", server.UserEndpoint)
mux.HandleFunc("/api/todo", server.TodoEndpoint)
mux.HandleFunc("/api/groups", server.TodoGroupEndpoint)
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()
}

33
src/server/validation.go

@ -0,0 +1,33 @@
package server
import (
"Unbewohnte/dela/db"
"fmt"
)
const (
MinimalUsernameLength uint = 3
ForbiddenUsernameCharacters string = "|<>\"'`\\/\u200b"
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.Username)) < MinimalUsernameLength {
return false, "Username is too small"
}
for _, char := range user.Username {
for _, forbiddenChar := range ForbiddenUsernameCharacters {
if char == forbiddenChar {
return false, fmt.Sprintf("Username contains a forbidden character \"%c\"", char)
}
}
}
if uint(len(user.Password)) < MinimalPasswordLength {
return false, "Password is too small"
}
return true, ""
}

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

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

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

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

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

12071
static/bootstrap/css/bootstrap.css vendored

File diff suppressed because it is too large Load Diff

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

4486
static/bootstrap/js/bootstrap.js vendored

File diff suppressed because it is too large Load Diff

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save