sync
This commit is contained in:
@@ -4,5 +4,6 @@ go 1.24.2
|
||||
|
||||
require (
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/neo4j/neo4j-go-driver/v5 v5.28.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/neo4j/neo4j-go-driver/v5 v5.28.0 h1:chDT68PHNa8JZRmjSkGzAbk1weLWo4rMtDvccvpobg0=
|
||||
github.com/neo4j/neo4j-go-driver/v5 v5.28.0/go.mod h1:Vff8OwT7QpLm7L2yYr85XNWe9Rbqlbeb9asNXJTHO4k=
|
||||
|
||||
+13
-5
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/neo4j/neo4j-go-driver/v5/neo4j"
|
||||
)
|
||||
|
||||
@@ -34,7 +35,7 @@ func serve() {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
driver_ctx := context.Background()
|
||||
driver, err := neo4j.NewDriverWithContext("bolt://localhost:7687", neo4j.BasicAuth("neo4j", "jzLbsy2C23WHzQ-", ""))
|
||||
driver, err := neo4j.NewDriverWithContext(os.Getenv("NEO4J_BOLT"), neo4j.BasicAuth("neo4j", os.Getenv("NEO4J_PASSWORD"), ""))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -43,18 +44,19 @@ func serve() {
|
||||
|
||||
err = driver.VerifyConnectivity(driver_ctx)
|
||||
if err != nil {
|
||||
log.Println("Error connecting to neo4j.")
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Println("neo4j connection established.")
|
||||
|
||||
rsrc_endpoints := []string{
|
||||
"/auth",
|
||||
"/users",
|
||||
"/users",
|
||||
"/users/{user_id}",
|
||||
"/users/{user_id}",
|
||||
"/users/{user_id}",
|
||||
"/users/{user_id}",
|
||||
"/users/{user_id}/posts",
|
||||
"/users/{user_id}/spaces",
|
||||
"/spaces",
|
||||
@@ -74,9 +76,9 @@ func serve() {
|
||||
|
||||
methods := []string{
|
||||
http.MethodPost,
|
||||
http.MethodGet,
|
||||
http.MethodGet,
|
||||
http.MethodPost,
|
||||
http.MethodGet,
|
||||
http.MethodGet,
|
||||
http.MethodPatch,
|
||||
http.MethodDelete,
|
||||
http.MethodGet,
|
||||
@@ -97,10 +99,10 @@ func serve() {
|
||||
}
|
||||
|
||||
functions := []func(http.ResponseWriter, *http.Request){
|
||||
auth_user(driver, driver_ctx),
|
||||
create_new_user(driver, driver_ctx),
|
||||
retrieve_all_users(driver, driver_ctx),
|
||||
retrieve_user(driver, driver_ctx),
|
||||
auth_user(driver, driver_ctx),
|
||||
update_user(driver, driver_ctx),
|
||||
delete_user(driver, driver_ctx),
|
||||
retrieve_users_posts(driver, driver_ctx),
|
||||
@@ -168,5 +170,11 @@ func serve() {
|
||||
}
|
||||
|
||||
func main() {
|
||||
err := godotenv.Load("../.env")
|
||||
if err != nil {
|
||||
log.Println("Error loading .env.")
|
||||
return
|
||||
}
|
||||
|
||||
serve()
|
||||
}
|
||||
|
||||
+106
-6
@@ -3,25 +3,113 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
// "fmt"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
//"io"
|
||||
// "log"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
// "os"
|
||||
// "os/signal"
|
||||
// "reflect"
|
||||
"math/rand"
|
||||
"strings"
|
||||
|
||||
// "sync"
|
||||
// "time"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/neo4j/neo4j-go-driver/v5/neo4j"
|
||||
)
|
||||
|
||||
func id_exists(id int, driver neo4j.DriverWithContext, driver_ctx context.Context) bool {
|
||||
result, _ := neo4j.ExecuteQuery(driver_ctx, driver,
|
||||
"MATCH (u:User) WHERE id(u) = $id RETURN count(u)",
|
||||
map[string]any{
|
||||
"id": id,
|
||||
}, neo4j.EagerResultTransformer,
|
||||
neo4j.ExecuteQueryWithDatabase("neo4j"))
|
||||
|
||||
if len(result.Records) > 0 {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func create_new_user(driver neo4j.DriverWithContext, driver_ctx context.Context) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:2025")
|
||||
|
||||
err := r.ParseMultipartForm(10 << 20) // Limit to 10MB
|
||||
if err != nil {
|
||||
log.Println("Unable to parse form.")
|
||||
}
|
||||
|
||||
username := r.FormValue("username")
|
||||
email := r.FormValue("email")
|
||||
password_hash := r.FormValue("password_hash")
|
||||
|
||||
id := rand.Intn(999999999999) + 1
|
||||
for id_exists(id, driver, driver_ctx) {
|
||||
id = rand.Intn(999999999999) + 1
|
||||
}
|
||||
|
||||
profile_picture_file, header, err := r.FormFile("profile_picture")
|
||||
if err != nil {
|
||||
log.Println("Error retrieving file from form.")
|
||||
}
|
||||
|
||||
defer profile_picture_file.Close()
|
||||
filename_parts := strings.Split(header.Filename, ".")
|
||||
ext := filename_parts[len(filename_parts)-1]
|
||||
|
||||
dst, err := os.Create(os.Getenv("PROFILE_IMAGES_FQ_PATH") + strconv.Itoa(id) + ext)
|
||||
if err != nil {
|
||||
log.Println("Error saving profile picture.")
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
_, err = io.Copy(dst, profile_picture_file)
|
||||
if err != nil {
|
||||
log.Println("Error writing file to filesystem.")
|
||||
}
|
||||
|
||||
result, _ := neo4j.ExecuteQuery(driver_ctx, driver,
|
||||
"CREATE (u:User {id: $id, username: $username, email: $email, password_hash: $password_hash, profile_picture: $profile_picture, created_at: datetime()})-[:USER_TYPE]->(:Role {name: 'User'}) RETURN elementId(u) AS id",
|
||||
map[string]any{
|
||||
"id": id,
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password_hash": password_hash,
|
||||
"profile_picture": strconv.Itoa(id) + ext,
|
||||
}, neo4j.EagerResultTransformer,
|
||||
neo4j.ExecuteQueryWithDatabase("neo4j"))
|
||||
|
||||
if len(result.Records) > 0 {
|
||||
ret := struct {
|
||||
Auth bool `json:"auth"`
|
||||
Id string `json:"id"`
|
||||
}{
|
||||
Auth: true,
|
||||
Id: strconv.Itoa(id),
|
||||
}
|
||||
|
||||
json, err := json.Marshal(ret)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, string(json))
|
||||
log.Println("User ID:", id, "Action: Signup")
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func retrieve_all_users(driver neo4j.DriverWithContext, driver_ctx context.Context) http.HandlerFunc {
|
||||
@@ -32,15 +120,16 @@ func retrieve_all_users(driver neo4j.DriverWithContext, driver_ctx context.Conte
|
||||
|
||||
func retrieve_user(driver neo4j.DriverWithContext, driver_ctx context.Context) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
user_id, _ := strconv.Atoi(mux.Vars(r)["user_id"])
|
||||
id, _ := strconv.Atoi(mux.Vars(r)["user_id"])
|
||||
|
||||
result, _ := neo4j.ExecuteQuery(driver_ctx, driver,
|
||||
"MATCH (u:User) WHERE id(u) = $user_id RETURN u.username AS username, u.email AS email, u.profile_picture AS profile_picture",
|
||||
map[string]any{
|
||||
"user_id": user_id,
|
||||
"user_id": id,
|
||||
}, neo4j.EagerResultTransformer,
|
||||
neo4j.ExecuteQueryWithDatabase("neo4j"))
|
||||
|
||||
if len(result.Records) > 0 {
|
||||
record := result.Records[0]
|
||||
vals := record.AsMap()
|
||||
user := struct {
|
||||
@@ -58,12 +147,20 @@ func retrieve_user(driver neo4j.DriverWithContext, driver_ctx context.Context) h
|
||||
return
|
||||
}
|
||||
|
||||
send_response(w, json)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, string(json))
|
||||
log.Println("User ID:", id, "Action: Retrieval")
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func auth_user(driver neo4j.DriverWithContext, driver_ctx context.Context) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:2025")
|
||||
|
||||
username := r.FormValue("username")
|
||||
password_hash := r.FormValue("password_hash")
|
||||
|
||||
@@ -96,7 +193,10 @@ func auth_user(driver neo4j.DriverWithContext, driver_ctx context.Context) http.
|
||||
return
|
||||
}
|
||||
|
||||
send_response(w, json)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, string(json))
|
||||
log.Println("User ID:", id, "Action: Login")
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ if (isset($_SESSION['username'])) {
|
||||
$nav .= '
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/nav.js" defer></script>
|
||||
</nav>
|
||||
';
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@ function login_form($error_msg = '')
|
||||
$form = '
|
||||
<div class="form_card">
|
||||
<h3 class="underline">Log In</h3>
|
||||
<form method="post">
|
||||
<form method="post" id="login_form">
|
||||
<input type="hidden" name="form_id" value="login_form">
|
||||
<input type="hidden" name="password_hash" value="" id="password_hash">
|
||||
|
||||
<label for="username">Username / Email</label>
|
||||
<input id="username" name="username" spellcheck="false" required>
|
||||
<input id="username" name="username" spellcheck="false" required autofocus>
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" spellcheck="false" required>
|
||||
@@ -21,6 +22,8 @@ function login_form($error_msg = '')
|
||||
<input class="button" type="submit" value="Log In">
|
||||
' . $error_msg . '
|
||||
</form>
|
||||
|
||||
<script src="js/login.js" defer></script>
|
||||
</div>
|
||||
';
|
||||
|
||||
|
||||
@@ -172,3 +172,37 @@
|
||||
.modal_activation_area:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/********** Animations **********/
|
||||
@view-transition {
|
||||
navigation: auto;
|
||||
animation-duration: 1s;
|
||||
}
|
||||
|
||||
::view-transition-old(root) {
|
||||
animation: fade-out 750ms forwards;
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
animation: fade-in 750ms forwards;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ if (isset($_POST['ajax_id'])) {
|
||||
case 'todo':
|
||||
handle_todo();
|
||||
break;
|
||||
case 'login':
|
||||
handle_login();
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
$response = [
|
||||
@@ -15,6 +18,22 @@ if (isset($_POST['ajax_id'])) {
|
||||
echo json_encode($response);
|
||||
}
|
||||
|
||||
function handle_login()
|
||||
{
|
||||
$response = ['log' => []];
|
||||
|
||||
if (isset($_POST['user_id']) && isset($_POST['username']) && isset($_POST['role'])) {
|
||||
$response['log'][] = 'Required parameters met. Logging user in.';
|
||||
$_SESSION['user_id'] = $_POST['user_id'];
|
||||
$_SESSION['username'] = $_POST['username'];
|
||||
$_SESSION['role_id'] = $_POST['role'];
|
||||
|
||||
$response['redirect'] = 'articles.php?view=list';
|
||||
}
|
||||
|
||||
echo json_encode($response);
|
||||
}
|
||||
|
||||
function handle_todo()
|
||||
{
|
||||
include 'functions/todo_functions.php';
|
||||
|
||||
@@ -40,38 +40,6 @@ function logout()
|
||||
header('Location: .');
|
||||
}
|
||||
|
||||
function login($username, $password, $stay_logged_in)
|
||||
{
|
||||
$password_hash = hash('sha256', $password);
|
||||
|
||||
$user = exec_stmt('SELECT user_id, username, role_id FROM user WHERE username = ? OR email = ? AND password_hash = ?', 'sss', $username, $username, $password_hash)->fetch_assoc();
|
||||
|
||||
if ($user == null) {
|
||||
$error_msg = '
|
||||
<div class="error">
|
||||
Username and/or Password are invalid!
|
||||
</div>
|
||||
';
|
||||
|
||||
return $error_msg;
|
||||
}
|
||||
|
||||
$_SESSION['user_id'] = $user['user_id'];
|
||||
$_SESSION['username'] = $user['username'];
|
||||
$_SESSION['role_id'] = $user['role_id'];
|
||||
|
||||
if ($stay_logged_in) {
|
||||
$token = bin2hex(random_bytes(64));
|
||||
// echo $token . '<br>';
|
||||
// echo strlen($token);
|
||||
setcookie('remember_user', $token, time() + 60 * 60 * 24 * 30, '/', '', true, true);
|
||||
|
||||
exec_stmt('INSERT INTO remember_user (token, user_id, remote_addr, http_forward) VALUES (?, ?, ?, ?)', 'siss', $token, $user['user_id'], hash('sha256', $_SERVER['REMOTE_ADDR']), (isset($_SERVER['HTTP_X_FORWARDED_FOR']) ? hash('sha256', $_SERVER['HTTP_X_FORWARDED_FOR']) : null));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function create_user($username, $email, $password, $verify_password, $role_id, $upload = null, $x = null, $y = null, $crop_width = null, $honeypot = null)
|
||||
{
|
||||
if (!empty($honeypot)) {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
session_start();
|
||||
header("Content-Security-Policy: default-src 'self'; font-src 'self' https://www.nerdfonts.com/assets/fonts/Symbols-2048-em%20Nerd%20Font%20Complete.woff2; style-src 'self' 'unsafe-inline' https://nerdfonts.com/assets/css/webfont.css https://www.nerdfonts.com/assets/css/webfont.css; script-src 'self' 'unsafe-inline'; img-src 'self';");
|
||||
header("Content-Security-Policy: default-src 'self' localhost:7477; font-src 'self' https://www.nerdfonts.com/assets/fonts/Symbols-2048-em%20Nerd%20Font%20Complete.woff2; style-src 'self' 'unsafe-inline' https://nerdfonts.com/assets/css/webfont.css https://www.nerdfonts.com/assets/css/webfont.css; script-src 'self' 'unsafe-inline'; img-src 'self';");
|
||||
|
||||
// Initialize Composer.
|
||||
require_once 'vendor/autoload.php';
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
const form = document.querySelector('#login_form');
|
||||
const username_field = form.querySelector('#username');
|
||||
const password_field = form.querySelector('#password');
|
||||
const password_hash_field = form.querySelector('#password_hash');
|
||||
const submit_button = form.querySelector('input[type="submit"]');
|
||||
|
||||
submit_button.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const password_hash = await hashString(password_field.value);
|
||||
const urlParams = new URLSearchParams(document.location.search);
|
||||
const redirect = decodeURI(urlParams.get('redirect'));
|
||||
requestAuth(username_field.value, password_hash, redirect);
|
||||
});
|
||||
|
||||
function requestAuth(username, password_hash, redirect) {
|
||||
fetch('http://localhost:7477/auth', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: 'username=' + username + '&password_hash=' + password_hash,
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
console.log('Login Failed!');
|
||||
}
|
||||
|
||||
return response.json(); // Parse response body as JSON
|
||||
})
|
||||
.then(data => {
|
||||
notifyServer(data["id"], username, 1, redirect);
|
||||
});
|
||||
}
|
||||
|
||||
function notifyServer(id, username, role, redirect) {
|
||||
fetch('director.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: 'ajax_id=login&user_id=' + id + '&username=' + username + '&role=' + role,
|
||||
})
|
||||
.then(response => {
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if ('startViewTransition' in document) {
|
||||
document.startViewTransition(() => {
|
||||
if (redirect == null) {
|
||||
window.location.href = 'articles.php?view=list&sort=new';
|
||||
} else {
|
||||
window.location.href = redirect;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Fallback for browsers that don't support View Transitions
|
||||
if (redirect == null) {
|
||||
window.location.href = 'articles.php?view=list&sort=new';
|
||||
} else {
|
||||
window.location.href = redirect;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function hashString(str) {
|
||||
const encoded = new TextEncoder().encode(str);
|
||||
const digest = await crypto.subtle.digest('SHA-256', encoded);
|
||||
const hashArray = Array.from(new Uint8Array(digest));
|
||||
const hashHex = hashArray.map(byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
return hashHex;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
const nav = document.querySelector('nav');
|
||||
const nav_links = nav.querySelectorAll('ul li a');
|
||||
const theme_toggle = nav.querySelector('button');
|
||||
|
||||
nav_links.forEach((el) => {
|
||||
el.addEventListener('click', () => {
|
||||
el.preventDefault();
|
||||
if ('startViewTransition' in document) {
|
||||
document.startViewTransition(() => {
|
||||
window.location.href = el.getAttribute('href');
|
||||
});
|
||||
} else {
|
||||
window.location.href = el.getAttribute('href');
|
||||
}
|
||||
});
|
||||
});
|
||||
+2
-63
@@ -15,72 +15,11 @@
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var todos = document.getElementsByClassName('todo_item');
|
||||
var action_items = (document.getElementById('todo_actions')).querySelectorAll('button');
|
||||
|
||||
// General Modal handling
|
||||
function handle_modal(modal) {
|
||||
console.log('handling modal');
|
||||
let modal_close = modal.querySelector('.modal_close');
|
||||
let submit_button = modal.querySelector('input[type="submit"]');
|
||||
modal.style.display = 'block';
|
||||
|
||||
modal_close.addEventListener('click', function() {
|
||||
modal.style.display = "none";
|
||||
});
|
||||
|
||||
submit_button.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
let todo = {
|
||||
'title': modal.querySelector('#title').value,
|
||||
'description': modal.querySelector('#description'),
|
||||
'priority': modal.querySelector('#priority'),
|
||||
'due_date': modal.querySelector('#due_date'),
|
||||
};
|
||||
|
||||
fetch('director.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: 'ajax_id=todo&action_id=create_update_todo&todo_id=' + this.todo_id + '&todo=' + encodeURIComponent(JSON.stringify(todo)),
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json(); // Or handle other response formats
|
||||
})
|
||||
.then(responseData => {
|
||||
console.log('Response received:', responseData);
|
||||
// Handle the server's response
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < action_items.length; i++) {
|
||||
let action = action_items[i];
|
||||
|
||||
action.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
switch (action.value) {
|
||||
case 'new':
|
||||
let new_todo_item = document.querySelector('#new_todo_item');
|
||||
handle_modal(new_todo_item);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
//var todos = document.querySelectorAll('todo_item');
|
||||
|
||||
for (let i = 0; i < todos.length; i++) {
|
||||
let item = todos[i];
|
||||
var item = todos[i];
|
||||
|
||||
// Detailed todo item view via a modal
|
||||
let modal_button = item.querySelector('.modal_activation_area');
|
||||
let modal = item.querySelector('.modal');
|
||||
modal_button.addEventListener('click', () => handle_modal(modal));
|
||||
|
||||
// Checkboxes & AJAX for updating the DB
|
||||
var checkbox = item.querySelector('input[type="checkbox"]');
|
||||
checkbox.todo_id = item.querySelector('input[name="todo_id"]').value;
|
||||
checkbox.is_checked = checkbox.checked;
|
||||
|
||||
Reference in New Issue
Block a user