Copying my other project structure to this project

This commit is contained in:
ditatompel 2024-05-04 00:11:56 +07:00
parent 97ac67022f
commit ced266159e
No known key found for this signature in database
GPG key ID: 31D3D06D77950979
81 changed files with 6637 additions and 0 deletions

44
.air.toml Normal file
View file

@ -0,0 +1,44 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/main"
cmd = "go build -o ./tmp/main ."
delay = 0
exclude_dir = ["assets", "tmp", "vendor", "testdata", "frontend/node_modules", "data", "bin"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html", "css", "js"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = false
keep_scroll = true

21
.editorconfig Normal file
View file

@ -0,0 +1,21 @@
; https://editorconfig.org/
root = true
[*]
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[{Makefile,go.mod,go.sum,*.go}]
indent_style = tab
indent_size = 4
[*.md]
indent_size = 4
trim_trailing_whitespace = false
[{*.yml,*.yaml}]
indent_style = space
indent_size = 2

17
.env.example Normal file
View file

@ -0,0 +1,17 @@
SECRET_KEY="" # must be 32 char length, use `openssl rand -base64 32` to generate random secret
LOG_LEVEL=INFO # can be DEBUG, INFO, WARNING, ERROR
# Fiber Config
APP_DEBUG=false # if this set to true , LOG_LEVEL will be set to DEBUG
APP_PREFORK=true
APP_HOST="0.0.0.0"
APP_PORT=18090
APP_PROXY_HEADER="X-Real-Ip" # CF-Connecting-IP
APP_ALLOW_ORIGIN="http://localhost:5173,http://192.168.1.99:5173,https://ditatompel.com"
# DB settings:
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=root
DB_PASSWORD=
DB_NAME=wa_ditatombot

16
Makefile Normal file
View file

@ -0,0 +1,16 @@
.PHONY: ui build linux64
BINARY_NAME = xmr-nodes
build: ui linux64
ui:
go generate ./...
linux64:
CGO_ENABLED=0 GOARCH=amd64 GOOS=linux go build -o bin/${BINARY_NAME}-static-linux-amd64
clean:
go clean
rm -rfv ./bin
rm -rf ./frontend/build

80
cmd/admin.go Normal file
View file

@ -0,0 +1,80 @@
package cmd
import (
"bufio"
"fmt"
"os"
"strings"
"syscall"
"github.com/ditatompel/xmr-nodes/internal/database"
"github.com/ditatompel/xmr-nodes/internal/repo"
"github.com/spf13/cobra"
"golang.org/x/term"
)
var adminCmd = &cobra.Command{
Use: "admin",
Short: "Create Admin",
Long: `Create an admin account for WebUI access.`,
Run: func(_ *cobra.Command, args []string) {
if len(args) == 0 {
fmt.Println("Usage: xmr-nodes admin create")
os.Exit(1)
}
if args[0] == "create" {
if err := database.ConnectDB(); err != nil {
panic(err)
}
if err := createAdmin(); err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("Admin account created")
os.Exit(0)
}
},
}
func init() {
rootCmd.AddCommand(adminCmd)
}
func createAdmin() error {
admin := repo.NewAdminRepo(database.GetDB())
a := repo.Admin{
Username: stringPrompt("Username:"),
Password: passPrompt("Password:"),
}
_, err := admin.CreateAdmin(&a)
return err
}
func stringPrompt(label string) string {
var s string
r := bufio.NewReader(os.Stdin)
for {
fmt.Fprint(os.Stderr, label+" ")
s, _ = r.ReadString('\n')
if s != "" {
break
}
}
return strings.TrimSpace(s)
}
func passPrompt(label string) string {
var s string
for {
fmt.Fprint(os.Stderr, label+" ")
b, _ := term.ReadPassword(int(syscall.Stdin))
s = string(b)
if s != "" {
break
}
}
fmt.Println()
return s
}

31
cmd/root.go Normal file
View file

@ -0,0 +1,31 @@
package cmd
import (
"os"
"github.com/ditatompel/xmr-nodes/internal/config"
"github.com/spf13/cobra"
)
const AppVer = "0.0.1"
var LogLevel string
var rootCmd = &cobra.Command{
Use: "xmr-nodes",
Short: "XMR Nodes",
Version: AppVer,
}
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
config.LoadAll(".env")
LogLevel = config.AppCfg().LogLevel
}

104
cmd/serve.go Normal file
View file

@ -0,0 +1,104 @@
package cmd
import (
"fmt"
"os"
"os/signal"
"syscall"
"github.com/ditatompel/xmr-nodes/frontend"
"github.com/ditatompel/xmr-nodes/handler"
"github.com/ditatompel/xmr-nodes/internal/config"
"github.com/ditatompel/xmr-nodes/internal/database"
"github.com/ditatompel/xmr-nodes/internal/repo"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/encryptcookie"
"github.com/gofiber/fiber/v2/middleware/filesystem"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/recover"
"github.com/spf13/cobra"
)
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Serve the WebUI",
Long: `This command will run HTTP server for APIs and WebUI.`,
Run: func(_ *cobra.Command, _ []string) {
serve()
},
}
func init() {
rootCmd.AddCommand(serveCmd)
}
func serve() {
appCfg := config.AppCfg()
// connect to DB
if err := database.ConnectDB(); err != nil {
panic(err)
}
// Define Fiber config & app.
app := fiber.New(fiberConfig())
// recover
app.Use(recover.New(recover.Config{EnableStackTrace: appCfg.Debug}))
// logger middleware
if appCfg.Debug {
app.Use(logger.New(logger.Config{
Format: "[${time}] ${status} - ${latency} ${method} ${path} ${queryParams} ${ip} ${ua}\n",
}))
}
app.Use(cors.New(cors.Config{
AllowOrigins: appCfg.AllowOrigin,
AllowHeaders: "Origin, Content-Type, Accept",
AllowCredentials: true,
}))
// cookie
app.Use(encryptcookie.New(encryptcookie.Config{Key: appCfg.SecretKey}))
handler.AppRoute(app)
handler.V1Api(app)
app.Use("/", filesystem.New(filesystem.Config{
Root: frontend.SvelteKitHandler(),
// NotFoundFile: "index.html",
}))
// signal channel to capture system calls
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT)
// start a cleanup cron-job
if !fiber.IsChild() {
cronRepo := repo.NewCron(database.GetDB())
go cronRepo.RunCronProcess()
}
// start shutdown goroutine
go func() {
// capture sigterm and other system call here
<-sigCh
fmt.Println("Shutting down HTTP server...")
_ = app.Shutdown()
}()
// start http server
serverAddr := fmt.Sprintf("%s:%d", appCfg.Host, appCfg.Port)
if err := app.Listen(serverAddr); err != nil {
fmt.Printf("Server is not running! error: %v", err)
}
}
func fiberConfig() fiber.Config {
return fiber.Config{
Prefork: config.AppCfg().Prefork,
ProxyHeader: config.AppCfg().ProxyHeader,
AppName: "ditatompel's XMR Nodes HTTP server " + AppVer,
}
}

13
frontend/.eslintignore Normal file
View file

@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

15
frontend/.eslintrc.cjs Normal file
View file

@ -0,0 +1,15 @@
/** @type { import("eslint").Linter.Config } */
module.exports = {
root: true,
extends: ['eslint:recommended', 'plugin:svelte/recommended', 'prettier'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2017: true,
node: true
}
};

10
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
frontend/.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

4
frontend/.prettierignore Normal file
View file

@ -0,0 +1,4 @@
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

8
frontend/.prettierrc Normal file
View file

@ -0,0 +1,8 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

35
frontend/README.md Normal file
View file

@ -0,0 +1,35 @@
# UI
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte).
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
## Deploying
after running `npm run build` from development device, copy `./build`, `package.json` and `package-lock.json` to server. On the server, run `npm ci --omit dev` then restart the systemd service.
Playbook example (run from root project):
```shell
ansible-playbook -i ./utils/ansible/inventory.ini -l production ./utils/ansible/deploy.yml -K
```

21
frontend/embed.go Normal file
View file

@ -0,0 +1,21 @@
package frontend
import (
"embed"
"io/fs"
"log"
"net/http"
)
//go:generate npm i
//go:generate npm run build
//go:embed all:build/*
var f embed.FS
func SvelteKitHandler() http.FileSystem {
build, err := fs.Sub(f, "build")
if err != nil {
log.Fatal(err)
}
return http.FS(build)
}

18
frontend/jsconfig.json Normal file
View file

@ -0,0 +1,18 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias and https://kit.svelte.dev/docs/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

4070
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

40
frontend/package.json Normal file
View file

@ -0,0 +1,40 @@
{
"name": "xmr-nodes-frontend",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "VITE_API_URL=http://127.0.0.1:18901 vite dev --host 127.0.0.1",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"devDependencies": {
"@floating-ui/dom": "^1.6.3",
"@skeletonlabs/skeleton": "^2.9.0",
"@skeletonlabs/tw-plugin": "^0.3.1",
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@tailwindcss/forms": "^0.5.7",
"@types/eslint": "^8.56.0",
"@vincjo/datatables": "^1.14.5",
"autoprefixer": "^10.4.17",
"date-fns": "^3.3.1",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.35.1",
"postcss": "^8.4.35",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"svelte": "^4.2.7",
"svelte-check": "^3.6.0",
"tailwindcss": "^3.4.1",
"typescript": "^5.0.0",
"vite": "^5.0.3"
},
"type": "module"
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

13
frontend/src/app.css Normal file
View file

@ -0,0 +1,13 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind variants;
html,
body {
@apply h-full;
}
.dashboard-card {
@apply bg-surface-50-900-token rounded-lg border-2 border-dashed border-gray-200 p-4 dark:border-gray-700;
}

16
frontend/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,16 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
interface ImportMetaEnv {
VITE_API_URL: string;
}
}
export {};

13
frontend/src/app.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="false" data-theme="skeleton">
<div style="display: contents" class="h-full">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,50 @@
<script>
import { onDestroy, createEventDispatcher } from 'svelte';
export let threshold = 0;
export let horizontal = false;
export let hasMore = true;
/** @type {any} */
let elementScroll;
const dispatch = createEventDispatcher();
let isLoadMore = false;
/** @type {any} */
let component;
$: {
if (component || elementScroll) {
const element = elementScroll ? elementScroll : component.parentNode;
element.addEventListener('scroll', onScroll);
element.addEventListener('resize', onScroll);
}
}
/** @param {any} e */
const onScroll = (e) => {
const offset = horizontal
? e.target.scrollWidth - e.target.clientWidth - e.target.scrollLeft
: e.target.scrollHeight - e.target.clientHeight - e.target.scrollTop;
if (offset <= threshold) {
if (!isLoadMore && hasMore) {
dispatch('loadMore');
}
isLoadMore = true;
} else {
isLoadMore = false;
}
};
onDestroy(() => {
if (component || elementScroll) {
const element = elementScroll ? elementScroll : component.parentNode;
element.removeEventListener('scroll', null);
element.removeEventListener('resize', null);
}
});
</script>
<div bind:this={component} class="w-0" />

View file

@ -0,0 +1,69 @@
<script lang="ts">
import type { DataHandler, Row } from '@vincjo/datatables/remote';
type T = $$Generic<Row>;
export let handler: DataHandler<T>;
const pageNumber = handler.getPageNumber();
const pageCount = handler.getPageCount();
const pages = handler.getPages({ ellipsis: true });
const setPage = (value: 'previous' | 'next' | number) => {
handler.setPage(value);
handler.invalidate();
};
</script>
<section class={$$props.class ?? ''}>
{#if $pages === undefined}
<button type="button" class="sm-btn" on:click={() => setPage('previous')}> &#10094; </button>
<button class="mx-4">page <b>{$pageNumber}</b></button>
<button type="button" class="sm-btn" on:click={() => setPage('next')}>&#10095;</button>
{:else}
<div class="lg:hidden">
<button type="button" class="sm-btn" on:click={() => setPage('previous')}> &#10094; </button>
<button class="mx-4">page <b>{$pageNumber}</b></button>
<button
class="sm-btn"
class:disabled={$pageNumber === $pageCount}
on:click={() => setPage('next')}
>
&#10095;
</button>
</div>
<div class="btn-group variant-ghost-surface hidden lg:block">
<button
type="button"
class="hover:variant-soft-secondary"
class:disabled={$pageNumber === 1}
on:click={() => setPage('previous')}>&#10094;</button
>
{#each $pages as page}<button
type="button"
class="hover:variant-filled-secondary"
class:!variant-filled-primary={$pageNumber === page}
class:ellipse={page === null}
on:click={() => setPage(page)}>{page ?? '...'}</button
>{/each}
<button
type="button"
class="hover:variant-soft-secondary"
class:disabled={$pageNumber === $pageCount}
on:click={() => setPage('next')}
>
&#10095;
</button>
</div>
{/if}
</section>
<style lang="postcss">
.sm-btn {
@apply btn btn-sm variant-ghost-surface hover:variant-soft-secondary;
}
.disabled {
@apply cursor-not-allowed;
}
</style>

View file

@ -0,0 +1,22 @@
<script lang="ts">
import type { DataHandler, Row } from '@vincjo/datatables/remote';
type T = $$Generic<Row>;
export let handler: DataHandler<T>;
const rowCount = handler.getRowCount();
</script>
{#if $rowCount === undefined}
<div />
{:else}
<div class={$$props.class ?? 'mr-6 leading-8 lg:leading-10'}>
{#if $rowCount.total > 0}
<b>{$rowCount.start}</b>
- <b>{$rowCount.end}</b>
/ <b>{$rowCount.total}</b>
{:else}
No entries found
{/if}
</div>
{/if}

View file

@ -0,0 +1,33 @@
<script lang="ts">
import type { DataHandler, Row } from '@vincjo/datatables/remote';
type T = $$Generic<Row>;
export let handler: DataHandler<T>;
export let options = [5, 10, 20, 50, 100];
export let labelId = 'rowsPerPage';
const rowsPerPage = handler.getRowsPerPage();
const setRowsPerPage = () => {
handler.setPage(1);
handler.invalidate();
};
</script>
<div class="flex place-items-center">
<label for={labelId}>Show</label>
<select
class="select ml-2"
id={labelId}
name="rowsPerPage"
bind:value={$rowsPerPage}
on:change={setRowsPerPage}
>
{#each options as option}
<option value={option}>
{option}
</option>
{/each}
</select>
</div>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import type { DataHandler } from '@vincjo/datatables/remote';
export let handler: DataHandler;
let value: string;
let timeout: any;
const search = () => {
handler.search(value);
clearTimeout(timeout);
timeout = setTimeout(() => {
handler.invalidate();
}, 400);
};
</script>
<input
class="input-variant-secondary input w-36 sm:w-64"
type="search"
name="tableGlobalSearch"
placeholder="Search..."
bind:value
on:input={search}
/>

View file

@ -0,0 +1,35 @@
<script lang="ts">
import type { DataHandler, Row } from '@vincjo/datatables/remote';
type T = $$Generic<Row>;
export let handler: DataHandler<T>;
export let filterBy: keyof T;
/** @type {string} */
export let placeholder: string = 'Filter';
/** @type {number} */
export let colspan: number = 1;
let value: string = '';
let timeout: any;
const filter = () => {
handler.filter(value, filterBy);
clearTimeout(timeout);
timeout = setTimeout(() => {
handler.invalidate();
}, 400);
};
</script>
<th {colspan}>
<input
class="input variant-form-material h-8 w-full text-sm"
type="text"
{placeholder}
bind:value
on:input={filter}
/>
</th>

View file

@ -0,0 +1,18 @@
<script lang="ts">
import type { DataHandler, Row } from '@vincjo/datatables/remote';
type T = $$Generic<Row>;
export let handler: DataHandler<T>;
export let orderBy: keyof T;
const update = () => {
handler.sort(orderBy);
handler.invalidate();
};
</script>
<th on:click={update} class="cursor-pointer select-none p-2 px-5">
<div class="flex h-full items-center justify-start gap-x-2">
<slot /> ↕️
</div>
</th>

View file

@ -0,0 +1,6 @@
export { default as DtSrPagination } from './DtSrPagination.svelte';
export { default as DtSrRowCount } from './DtSrRowCount.svelte';
export { default as DtSrRowsPerPage } from './DtSrRowsPerPage.svelte';
export { default as DtSrSearch } from './DtSrSearch.svelte';
export { default as DtSrThFilter } from './DtSrThFilter.svelte';
export { default as DtSrThSort } from './DtSrThSort.svelte';

View file

@ -0,0 +1,24 @@
<script>
import { page } from '$app/stores';
import { navs } from './navs';
import { getDrawerStore } from '@skeletonlabs/skeleton';
const drawerStore = getDrawerStore();
function drawerClose() {
drawerStore.close();
}
$: classesActive = (/** @type {string} */ href) =>
$page.url.pathname.startsWith(href) ? 'bg-primary-500' : '';
</script>
<nav class="list-nav p-4">
<ul>
{#each navs as nav}
<li>
<a href={nav.path} class={classesActive(nav.path)} on:click={drawerClose}>{nav.name}</a>
</li>
{/each}
</ul>
</nav>

View file

@ -0,0 +1,85 @@
<script>
import { invalidateAll, goto } from '$app/navigation';
import { LightSwitch, getDrawerStore } from '@skeletonlabs/skeleton';
import { apiUri } from '$lib/utils/common';
const drawerStore = getDrawerStore();
function drawerOpen() {
drawerStore.open({});
}
/**
* @typedef formResult
* @type {object}
* @property {string} status
* @property {string} message
* @property {null | object} data
*/
/** @type {formResult} */
let formResult;
/** @param {{ currentTarget: EventTarget & HTMLFormElement}} event */
async function handleLogout(event) {
const data = new FormData(event.currentTarget);
const response = await fetch(event.currentTarget.action, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify(Object.fromEntries(data))
});
formResult = await response.json();
if (formResult.status === 'ok') {
// rerun all `load` functions, following the successful update
await invalidateAll();
goto('/login/');
}
}
</script>
<nav class="bg-surface-100-800-token fixed top-0 z-30 w-full shadow-2xl">
<div class="px-3 py-2 lg:px-5 lg:pl-3">
<div class="flex items-center justify-between">
<div class="flex items-center justify-start rtl:justify-end">
<button
class="btn btn-sm inline-flex items-center md:hidden"
aria-label="Mobile Drawer Button"
on:click={drawerOpen}
>
<span>
<svg viewBox="0 0 100 80" class="fill-token h-4 w-4">
<rect width="100" height="20" />
<rect y="30" width="100" height="20" />
<rect y="60" width="100" height="20" />
</svg>
</span>
</button>
<a href="/app/dashboard/" class="ms-2 flex md:me-24" aria-label="title">
<span class="hidden self-center whitespace-nowrap text-2xl font-semibold lg:block"
>XMR Nodes</span
>
</a>
</div>
<div class="flex items-center">
<div class="ms-3 flex items-center space-x-4">
<LightSwitch />
<form
action={apiUri('/auth/logout')}
method="POST"
on:submit|preventDefault={handleLogout}
>
<input type="hidden" name="logout" value="logout" />
<button type="submit" class="btn btn-sm variant-filled-error" role="menuitem">
Sign out
</button>
</form>
</div>
</div>
</div>
</div>
</nav>

View file

@ -0,0 +1,34 @@
<script>
import { page } from '$app/stores';
import { navs } from './navs';
</script>
<aside
id="logo-sidebar"
class="bg-surface-100-800-token fixed left-0 top-0 z-20 h-screen w-64 -translate-x-full pt-20 shadow-2xl transition-transform sm:translate-x-0"
aria-label="Sidebar"
>
<div class="h-full overflow-y-auto px-3 pb-4">
<ul class="space-y-2 font-medium list-none" data-sveltekit-preload-data="false">
{#each navs as nav}
<li>
<a
href={nav.path}
class={$page.url.pathname.startsWith(nav.path) ? 'active' : 'nav-link'}
>
<span class="ms-3">{nav.name}</span>
</a>
</li>
{/each}
</ul>
</div>
</aside>
<style lang="postcss">
.active {
@apply flex items-center rounded-lg bg-primary-500 p-2;
}
.nav-link {
@apply flex items-center rounded-lg p-2 hover:bg-secondary-500 hover:text-white;
}
</style>

View file

@ -0,0 +1,3 @@
export { default as AdminNav } from './AdminNav.svelte';
export { default as AdminSidebar } from './AdminSidebar.svelte';
export { default as AdminMobileDrawer } from './AdminMobileDrawer.svelte';

View file

@ -0,0 +1,5 @@
export const navs = [
{ name: 'Dashboard', path: '/app/dashboard/' },
{ name: 'Prober', path: '/app/prober/' },
{ name: 'Crons', path: '/app/crons/' }
];

View file

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View file

@ -0,0 +1,4 @@
/** @param {string} path */
export const apiUri = (path) => {
return `${import.meta.env.VITE_API_URL || ''}${path}`;
};

View file

@ -0,0 +1,21 @@
<script>
import { Toast, Drawer, Modal } from '@skeletonlabs/skeleton';
import { AdminNav, AdminSidebar, AdminMobileDrawer } from '$lib/components/navigation';
</script>
<Toast />
<Modal />
<Drawer>
<h2 class="p-4">Navigation</h2>
<hr />
<AdminMobileDrawer />
<hr />
</Drawer>
<AdminNav />
<AdminSidebar />
<div class="min-h-screen bg-gray-100/80 p-4 pt-14 dark:bg-gray-900/80 sm:ml-64">
<slot />
</div>

View file

@ -0,0 +1,174 @@
<script>
import { DataHandler } from '@vincjo/datatables/remote';
import { format, formatDistance } from 'date-fns';
import { loadData } from './api-handler';
import { onMount, onDestroy } from 'svelte';
import { DtSrThSort, DtSrThFilter, DtSrRowCount } from '$lib/components/datatables/server';
const handler = new DataHandler([], { rowsPerPage: 1000, totalRows: 0 });
let rows = handler.getRows();
const reloadData = () => {
handler.invalidate();
};
/** @type {string | number} */
let filterState = -1;
/** @type {string | number} */
let filterEnabled = -1;
/** @type {number | undefined} */
let intervalId;
let intervalValue = 0;
const intervalOptions = [
{ value: 0, label: 'No' },
{ value: 5, label: '5s' },
{ value: 10, label: '10s' },
{ value: 30, label: '30s' },
{ value: 60, label: '1m' }
];
const startInterval = () => {
const seconds = intervalValue;
if (isNaN(seconds) || seconds < 0) {
return;
}
if (!intervalOptions.some((option) => option.value === seconds)) {
return;
}
if (intervalId) {
clearInterval(intervalId);
}
if (seconds > 0) {
reloadData();
intervalId = setInterval(() => {
reloadData();
}, seconds * 1000);
}
};
$: startInterval(); // Automatically start the interval on change
onDestroy(() => {
clearInterval(intervalId); // Clear the interval when the component is destroyed
});
onMount(() => {
handler.onChange((state) => loadData(state));
handler.invalidate();
});
</script>
<div class="mb-4">
<h1 class="h2 font-extrabold dark:text-white">Crons</h1>
</div>
<div class="dashboard-card">
<div class="flex justify-between">
<div class="invisible flex place-items-center md:visible">
<label for="autoRefreshInterval">Auto Refresh:</label>
<select
class="select ml-2"
id="autoRefreshInterval"
bind:value={intervalValue}
on:change={startInterval}
>
{#each intervalOptions as { value, label }}
<option {value}>{label}</option>
{/each}
</select>
</div>
<div class="flex place-items-center">
<button
id="reloadDt"
name="reloadDt"
class="variant-filled-primary btn"
on:click={reloadData}>Reload</button
>
</div>
</div>
<div class="my-2 overflow-x-auto">
<table class="table table-hover table-compact w-full table-auto">
<thead>
<tr>
<DtSrThSort {handler} orderBy="id">ID</DtSrThSort>
<th>Title</th>
<th>Slug</th>
<th>Description</th>
<DtSrThSort {handler} orderBy="run_every">Run Every</DtSrThSort>
<DtSrThSort {handler} orderBy="last_run">Last Run</DtSrThSort>
<DtSrThSort {handler} orderBy="next_run">Next Run</DtSrThSort>
<DtSrThSort {handler} orderBy="run_time">Run Time</DtSrThSort>
<th>State</th>
<th>Enabled</th>
</tr>
<tr>
<DtSrThFilter {handler} filterBy="title" placeholder="Title" colspan={3} />
<DtSrThFilter {handler} filterBy="description" placeholder="Description" colspan={5} />
<th>
<select
id="fState"
name="fState"
class="select variant-form-material"
bind:value={filterState}
on:change={() => {
handler.filter(filterState, 'cron_state');
reloadData();
}}
>
<option value={-1}>Any</option>
<option value={1}>Running</option>
<option value={0}>Idle</option>
</select>
</th>
<th>
<select
id="fEnabled"
name="fEnabled"
class="select variant-form-material"
bind:value={filterEnabled}
on:change={() => {
handler.filter(filterEnabled, 'is_enabled');
reloadData();
}}
>
<option value={-1}>Any</option>
<option value={1}>Yes</option>
<option value={0}>No</option>
</select>
</th>
</tr>
</thead>
<tbody>
{#each $rows as row (row.id)}
<tr>
<td>{row.id}</td>
<td>{row.title}</td>
<td>{row.slug}</td>
<td>{row.description}</td>
<td>{row.run_every}s</td>
<td>
{format(row.last_run * 1000, 'PP HH:mm')}<br />
{formatDistance(row.last_run * 1000, new Date(), { addSuffix: true })}
</td>
<td>
{format(row.next_run * 1000, 'PP HH:mm')}<br />
{formatDistance(row.next_run * 1000, new Date(), { addSuffix: true })}
</td>
<td>{row.run_time}</td>
<td>{row.cron_state ? 'RUNNING' : 'IDLE'}</td>
<td>{row.is_enabled ? 'ENABLED' : 'DISABLED'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div class="flex justify-between mb-2">
<DtSrRowCount {handler} />
</div>
</div>

View file

@ -0,0 +1,21 @@
import { apiUri } from '$lib/utils/common';
/** @param {import('@vincjo/datatables/remote/state')} state */
export const loadData = async (state) => {
const response = await fetch(apiUri(`/api/v1/crons?${getParams(state)}`));
const json = await response.json();
state.setTotalRows(json.data.length ?? 0);
return json.data ?? [];
};
const getParams = ({ pageNumber, rowsPerPage, sort, filters }) => {
let params = `page=${pageNumber}&limit=${rowsPerPage}`;
if (sort) {
params += `&orderBy=${sort.orderBy}&orderDir=${sort.direction}`;
}
if (filters) {
params += filters.map(({ filterBy, value }) => `&${filterBy}=${value}`).join('');
}
return params;
};

View file

@ -0,0 +1,3 @@
<div class="mb-4">
<h1 class="h2 font-extrabold dark:text-white">Dashboard</h1>
</div>

View file

@ -0,0 +1,120 @@
<script>
import { DataHandler } from '@vincjo/datatables/remote';
import { format, formatDistance } from 'date-fns';
import { loadData } from './api-handler';
import { onMount, onDestroy } from 'svelte';
import { DtSrThSort, DtSrThFilter, DtSrRowCount } from '$lib/components/datatables/server';
const handler = new DataHandler([], { rowsPerPage: 10, totalRows: 0 });
let rows = handler.getRows();
const reloadData = () => {
handler.invalidate();
};
/** @type {number | undefined} */
let intervalId;
let intervalValue = 0;
const intervalOptions = [
{ value: 0, label: 'No' },
{ value: 5, label: '5s' },
{ value: 10, label: '10s' },
{ value: 30, label: '30s' },
{ value: 60, label: '1m' }
];
const startInterval = () => {
const seconds = intervalValue;
if (isNaN(seconds) || seconds < 0) {
return;
}
if (!intervalOptions.some((option) => option.value === seconds)) {
return;
}
if (intervalId) {
clearInterval(intervalId);
}
if (seconds > 0) {
reloadData();
intervalId = setInterval(() => {
reloadData();
}, seconds * 1000);
}
};
$: startInterval(); // Automatically start the interval on change
onDestroy(() => {
clearInterval(intervalId); // Clear the interval when the component is destroyed
});
onMount(() => {
handler.onChange((state) => loadData(state));
handler.invalidate();
});
</script>
<div class="mb-4">
<h1 class="h2 font-extrabold dark:text-white">Prober</h1>
<a class="variant-filled-success btn btn-sm mb-4" href="/app/prober/add">Add Prober</a>
</div>
<div class="dashboard-card">
<div class="flex justify-between">
<div class="invisible flex place-items-center md:visible">
<label for="autoRefreshInterval">Auto Refresh:</label>
<select
class="select ml-2"
id="autoRefreshInterval"
bind:value={intervalValue}
on:change={startInterval}
>
{#each intervalOptions as { value, label }}
<option {value}>{label}</option>
{/each}
</select>
</div>
<div class="flex place-items-center">
<button id="reloadDt" name="reloadDt" class="variant-filled-primary btn" on:click={reloadData}
>Reload</button
>
</div>
</div>
<div class="my-2 overflow-x-auto">
<table class="table table-hover table-compact w-full table-auto">
<thead>
<tr>
<DtSrThSort {handler} orderBy="id">ID</DtSrThSort>
<th>Name</th>
<th>API Key</th>
<DtSrThSort {handler} orderBy="last_submit_ts">Last Submit</DtSrThSort>
</tr>
<tr>
<DtSrThFilter {handler} filterBy="name" placeholder="Name" colspan={2} />
<DtSrThFilter {handler} filterBy="api_key" placeholder="API Key" colspan={2} />
</tr>
</thead>
<tbody>
{#each $rows as row (row.id)}
<tr>
<td>{row.id}</td>
<td>{row.name}</td>
<td>{row.api_key}</td>
<td>
{format(row.last_submit_ts * 1000, 'PP HH:mm')}<br />
{formatDistance(row.last_submit_ts * 1000, new Date(), { addSuffix: true })}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div class="flex justify-between mb-2">
<DtSrRowCount {handler} />
</div>
</div>

View file

@ -0,0 +1,94 @@
<script>
import { invalidateAll, goto } from '$app/navigation';
import { apiUri } from '$lib/utils/common';
import { ProgressBar } from '@skeletonlabs/skeleton';
/**
* @typedef formResult
* @type {object}
* @property {string} status
* @property {string} message
* @property {null | object} data
*/
/** @type {formResult} */
export let formResult;
let isProcessing = false;
/** @param {{ currentTarget: EventTarget & HTMLFormElement}} event */
async function handleSubmit(event) {
isProcessing = true;
const data = new FormData(event.currentTarget);
const response = await fetch(event.currentTarget.action, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify(Object.fromEntries(data))
});
formResult = await response.json();
isProcessing = false;
if (formResult.status === 'ok') {
// rerun all `load` functions, following the successful update
await invalidateAll();
goto('/app/prober/');
}
}
</script>
<div class="mb-4">
<h1 class="h2 font-extrabold dark:text-white">Add Prober</h1>
</div>
{#if !isProcessing}
{#if formResult?.status === 'error'}
<div class="p-4 mb-4 text-sm rounded-lg bg-gray-700 text-red-400" role="alert">
<span class="font-medium">Error:</span>
{formResult.message}!
</div>
{/if}
{#if formResult?.status === 'ok'}
<div class="p-4 mb-4 text-sm rounded-lg bg-gray-700 text-green-400" role="alert">
<span class="font-medium">Success:</span>
{formResult.message}!
</div>
{/if}
{:else}
<ProgressBar meter="bg-secondary-500" track="bg-secondary-500/30" value={undefined} />
<div class="mx-4 p-4 mb-4 text-sm rounded-lg bg-gray-700 text-blue-400" role="alert">
<span class="font-medium">Processing...</span>
</div>
{/if}
<div class="dashboard-card">
<form
class="space-y-4 md:space-y-6"
action={apiUri('/api/v1/prober')}
method="POST"
on:submit|preventDefault={handleSubmit}
>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label for="name" class="label">
<span>Name</span>
<input
type="text"
name="name"
id="name"
placeholder="Prober name"
autocomplete="off"
class="input variant-form-material"
/>
</label>
</div>
</div>
<button
type="submit"
class="w-full rounded-lg bg-primary-600 px-5 py-2.5 text-center text-sm font-medium hover:bg-primary-700"
>Submit</button
>
</form>
</div>

View file

@ -0,0 +1,21 @@
import { apiUri } from '$lib/utils/common';
/** @param {import('@vincjo/datatables/remote/state')} state */
export const loadData = async (state) => {
const response = await fetch(apiUri(`/api/v1/prober?${getParams(state)}`));
const json = await response.json();
state.setTotalRows(json.data.length ?? 0);
return json.data.items ?? [];
};
const getParams = ({ pageNumber, rowsPerPage, sort, filters }) => {
let params = `page=${pageNumber}&limit=${rowsPerPage}`;
if (sort) {
params += `&orderBy=${sort.orderBy}&orderDir=${sort.direction}`;
}
if (filters) {
params += filters.map(({ filterBy, value }) => `&${filterBy}=${value}`).join('');
}
return params;
};

View file

@ -0,0 +1,2 @@
export const prerender = true
export const trailingSlash = 'always';

View file

@ -0,0 +1,34 @@
<script>
// import { base } from '$app/paths';
import '../app.css';
import { beforeNavigate, afterNavigate } from '$app/navigation';
import { computePosition, autoUpdate, offset, shift, flip, arrow } from '@floating-ui/dom'
import {
ProgressBar,
initializeStores,
storePopup // PopUps
} from '@skeletonlabs/skeleton';
initializeStores();
// popups
storePopup.set({ computePosition, autoUpdate, offset, shift, flip, arrow });
let isLoading = false;
// progress bar show
beforeNavigate(() => (isLoading = true));
afterNavigate((/* params */) => {
isLoading = false;
});
</script>
{#if isLoading}
<ProgressBar
class="fixed top-0 z-50"
height="h-1"
track="bg-opacity-100"
meter="bg-gradient-to-br from-purple-600 via-pink-600 to-blue-600"
/>
{/if}
<slot />

View file

@ -0,0 +1,7 @@
<div class="w-full h-full flex justify-center items-center">
<div class="text-center space-y-4">
<h1 class="h1">( . ) ( . )</h1>
<p>WAT?</p>
</div>
</div>

View file

@ -0,0 +1,118 @@
<script>
import { invalidateAll, goto } from '$app/navigation';
import { apiUri } from '$lib/utils/common';
import { ProgressBar, LightSwitch } from '@skeletonlabs/skeleton';
/**
* @typedef formResult
* @type {object}
* @property {string} status
* @property {string} message
* @property {null | object} data
*/
/** @type {formResult} */
export let formResult;
let isProcessing = false;
/** @param {{ currentTarget: EventTarget & HTMLFormElement}} event */
async function handleSubmit(event) {
isProcessing = true;
const data = new FormData(event.currentTarget);
const response = await fetch(event.currentTarget.action, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify(Object.fromEntries(data))
});
formResult = await response.json();
isProcessing = false;
if (formResult.status === 'ok') {
// rerun all `load` functions, following the successful update
await invalidateAll();
goto('/app/dashboard/');
}
}
</script>
<section class="bg-gray-50 dark:bg-gray-900">
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<a href="/" class="flex items-center mb-6 text-2xl font-semibold text-gray-900 dark:text-white"
>XMR Nodes</a
>
<div
class="w-full rounded-lg shadow border md:mt-0 sm:max-w-md xl:p-0 bg-white border-gray-700 dark:bg-gray-800"
>
<div class="p-6 space-y-4 md:space-y-6 sm:p-8">
<h1
class="text-xl font-bold leading-tight tracking-tight tmd:text-2xl text-gray-900 dark:text-white"
>
Sign in to your account
</h1>
<form
class="space-y-4 md:space-y-6"
action={apiUri('/auth/login')}
method="POST"
on:submit|preventDefault={handleSubmit}
>
<div>
<label
for="username"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label
>
<input
type="text"
name="username"
id="username"
class="input"
placeholder="username"
required
/>
</div>
<div>
<label
for="password"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label
>
<input
type="password"
name="password"
id="password"
placeholder="••••••••"
class="input"
required
/>
</div>
<button type="submit" class="btn variant-filled-primary w-full">Sign in</button>
<LightSwitch />
</form>
</div>
{#if !isProcessing}
{#if formResult?.status === 'error'}
<div class="mx-4 p-4 mb-4 text-sm rounded-lg bg-gray-700 text-red-400" role="alert">
<span class="font-medium">Error:</span>
{formResult.message}!
</div>
{/if}
{#if formResult?.status === 'ok'}
<div class="mx-4 p-4 mb-4 text-sm rounded-lg bg-gray-700 text-green-400" role="alert">
<span class="font-medium">Success:</span>
{formResult.message}!
</div>
{/if}
{:else}
<ProgressBar meter="bg-secondary-500" track="bg-secondary-500/30" value={undefined} />
<div class="mx-4 p-4 mb-4 text-sm rounded-lg bg-gray-700 text-blue-400" role="alert">
<span class="font-medium">Processing...</span>
</div>
{/if}
</div>
</div>
</section>

BIN
frontend/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
frontend/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -0,0 +1,63 @@
{
"name": "xmr.ditatompel.com",
"short_name": "xmr-nodes",
"start_url": "/",
"display": "standalone",
"background_color": "#272b31",
"theme_color": "#272b31",
"description": "WA Bot UI",
"icons": [{
"src": "/img/icon/android-icon-36x36.png",
"sizes": "36x36",
"type": "image/png",
"purpose": "any",
"density": "0.75"
}, {
"src": "/img/icon/android-icon-48x48.png",
"sizes": "48x48",
"type": "image/png",
"purpose": "any",
"density": "1.0"
},
{
"src": "/img/icon/android-icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any",
"density": "1.5"
},
{
"src": "/img/icon/android-icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any",
"density": "2.0"
},
{
"src": "/img/icon/android-icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "any",
"density": "3.0"
},
{
"src": "/img/icon/android-icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any",
"density": "4.0"
},
{
"src": "/img/icon/android-icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any",
"density": "4.0"
},
{
"src": "/img/icon/maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}]
}

View file

@ -0,0 +1,2 @@
User-agent: *
Disallow: /

16
frontend/svelte.config.js Normal file
View file

@ -0,0 +1,16 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
// paths: {
// base: '/'
// },
// trailingSlash: 'always',
adapter: adapter()
}
};
export default config;

View file

@ -0,0 +1,23 @@
import { join } from 'path';
import { skeleton } from '@skeletonlabs/tw-plugin';
import forms from '@tailwindcss/forms';
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: [
'./src/**/*.{html,js,svelte,ts}',
join(require.resolve('@skeletonlabs/skeleton'), '../**/*.{html,js,svelte,ts}')
],
theme: {
extend: {}
},
plugins: [
forms,
skeleton({
themes: {
preset: ['skeleton']
}
})
]
};

BIN
frontend/tmp/main Normal file

Binary file not shown.

9
frontend/vite.config.js Normal file
View file

@ -0,0 +1,9 @@
// @ts-check
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
sveltekit(),
]
});

31
go.mod Normal file
View file

@ -0,0 +1,31 @@
module github.com/ditatompel/xmr-nodes
go 1.22.2
require (
github.com/alexedwards/argon2id v1.0.0
github.com/go-sql-driver/mysql v1.8.1
github.com/gofiber/fiber/v2 v2.52.4
github.com/google/uuid v1.6.0
github.com/jmoiron/sqlx v1.4.0
github.com/joho/godotenv v1.5.1
github.com/spf13/cobra v1.8.0
golang.org/x/term v0.19.0
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/andybalholm/brotli v1.0.5 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.17.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/sys v0.19.0 // indirect
)

93
go.sum Normal file
View file

@ -0,0 +1,93 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/gofiber/fiber/v2 v2.52.4 h1:P+T+4iK7VaqUsq2PALYEfBBo6bJZ4q3FP8cZ84EggTM=
github.com/gofiber/fiber/v2 v2.52.4/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
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/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

18
handler/middlewares.go Normal file
View file

@ -0,0 +1,18 @@
package handler
import (
"github.com/gofiber/fiber/v2"
)
func CookieProtected(c *fiber.Ctx) error {
cookie := c.Cookies("xmr-nodes-ui")
if cookie == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"status": "error",
"message": "Unauthorized",
"data": nil,
})
}
return c.Next()
}

132
handler/response.go Normal file
View file

@ -0,0 +1,132 @@
package handler
import (
"fmt"
"time"
"github.com/ditatompel/xmr-nodes/internal/database"
"github.com/ditatompel/xmr-nodes/internal/repo"
"github.com/gofiber/fiber/v2"
)
func Login(c *fiber.Ctx) error {
payload := repo.Admin{}
if err := c.BodyParser(&payload); err != nil {
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
"status": "error",
"message": err.Error(),
"data": nil,
})
}
repo := repo.NewAdminRepo(database.GetDB())
res, err := repo.Login(payload.Username, payload.Password)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"status": "error",
"message": err.Error(),
"data": nil,
})
}
token := fmt.Sprintf("auth_%d_%d", res.Id, time.Now().Unix())
c.Cookie(&fiber.Cookie{
Name: "xmr-nodes-ui",
Value: token,
Expires: time.Now().Add(time.Hour * 24),
HTTPOnly: true,
})
return c.JSON(fiber.Map{
"status": "ok",
"message": "Logged in",
"data": nil,
})
}
func Logout(c *fiber.Ctx) error {
c.Cookie(&fiber.Cookie{
Name: "xmr-nodes-ui",
Value: "",
Expires: time.Now(),
HTTPOnly: true,
})
return c.JSON(fiber.Map{
"status": "ok",
"message": "Logged out",
"data": nil,
})
}
func Prober(c *fiber.Ctx) error {
proberRepo := repo.NewProberRepo(database.GetDB())
if c.Method() == "POST" {
payload := repo.Prober{}
if err := c.BodyParser(&payload); err != nil {
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
"status": "error",
"message": err.Error(),
"data": nil,
})
}
if payload.Name == "" {
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
"status": "error",
"message": "Please fill prober name",
"data": nil,
})
}
err := proberRepo.AddProber(payload.Name)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"status": "error",
"message": err.Error(),
"data": nil,
})
}
}
query := repo.ProbersQueryParams{
RowsPerPage: c.QueryInt("limit", 10),
Page: c.QueryInt("page", 1),
Name: c.Query("name"),
ApiKey: c.Query("api_key"),
}
prober, err := proberRepo.Probers(query)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"status": "error",
"message": err.Error(),
"data": nil,
})
}
return c.JSON(fiber.Map{
"status": "ok",
"message": "Success",
"data": prober,
})
}
func Crons(c *fiber.Ctx) error {
cronRepo := repo.NewCron(database.GetDB())
crons, err := cronRepo.Crons()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"status": "error",
"message": err.Error(),
"data": nil,
})
}
return c.JSON(fiber.Map{
"status": "ok",
"message": "Crons",
"data": crons,
})
}

18
handler/routes.go Normal file
View file

@ -0,0 +1,18 @@
package handler
import (
"github.com/gofiber/fiber/v2"
)
func AppRoute(app *fiber.App) {
app.Post("/auth/login", Login)
app.Post("/auth/logout", Logout)
}
func V1Api(app *fiber.App) {
v1 := app.Group("/api/v1")
v1.Get("/prober", Prober)
v1.Post("/prober", Prober)
v1.Get("/crons", Crons)
}

41
internal/config/app.go Normal file
View file

@ -0,0 +1,41 @@
package config
import (
"os"
"strconv"
)
type App struct {
Debug bool
Prefork bool
Host string
Port int
ProxyHeader string
AllowOrigin string
SecretKey string
LogLevel string
}
var app = &App{}
func AppCfg() *App {
return app
}
// LoadApp loads App configuration
func LoadApp() {
app.Host = os.Getenv("APP_HOST")
app.Port, _ = strconv.Atoi(os.Getenv("APP_PORT"))
app.Debug, _ = strconv.ParseBool(os.Getenv("APP_DEBUG"))
app.Prefork, _ = strconv.ParseBool(os.Getenv("APP_PREFORK"))
app.ProxyHeader = os.Getenv("APP_PROXY_HEADER")
app.AllowOrigin = os.Getenv("APP_ALLOW_ORIGIN")
app.SecretKey = os.Getenv("SECRET_KEY")
app.LogLevel = os.Getenv("LOG_LEVEL")
if app.LogLevel == "" {
app.LogLevel = "INFO"
}
if app.Debug {
app.LogLevel = "DEBUG"
}
}

18
internal/config/config.go Normal file
View file

@ -0,0 +1,18 @@
package config
import (
"log"
"github.com/joho/godotenv"
)
// LoadAllConfigs set various configs
func LoadAll(envFile string) {
err := godotenv.Load(envFile)
if err != nil {
log.Fatalf("can't load .env file. error: %v", err)
}
LoadApp()
LoadDBCfg()
}

31
internal/config/db.go Normal file
View file

@ -0,0 +1,31 @@
package config
import (
"os"
"strconv"
)
// DB holds the DB configuration
type DB struct {
Host string
Port int
Name string
User string
Password string
}
var db = &DB{}
// DBCfg returns the default DB configuration
func DBCfg() *DB {
return db
}
// LoadDBCfg loads DB configuration
func LoadDBCfg() {
db.Host = os.Getenv("DB_HOST")
db.Port, _ = strconv.Atoi(os.Getenv("DB_PORT"))
db.User = os.Getenv("DB_USER")
db.Password = os.Getenv("DB_PASSWORD")
db.Name = os.Getenv("DB_NAME")
}

View file

@ -0,0 +1,50 @@
package database
import (
"fmt"
"github.com/ditatompel/xmr-nodes/internal/config"
_ "github.com/go-sql-driver/mysql"
"github.com/jmoiron/sqlx"
)
// DB holds the database
type DB struct{ *sqlx.DB }
// database instance
var defaultDB = &DB{}
// connect sets the db client of database using configuration
func (db *DB) connect(cfg *config.DB) (err error) {
dbURI := fmt.Sprintf("%s:%s@(%s:%d)/%s",
cfg.User,
cfg.Password,
cfg.Host,
cfg.Port,
cfg.Name,
)
db.DB, err = sqlx.Connect("mysql", dbURI)
if err != nil {
return err
}
// Try to ping database.
if err := db.Ping(); err != nil {
defer db.Close() // close database connection
return fmt.Errorf("can't sent ping to database, %w", err)
}
return nil
}
// GetDB returns db instance
func GetDB() *DB {
return defaultDB
}
// ConnectDB sets the db client of database using default configuration
func ConnectDB() error {
return defaultDB.connect(config.DBCfg())
}

169
internal/repo/admin.go Normal file
View file

@ -0,0 +1,169 @@
package repo
import (
"errors"
"fmt"
"strings"
"time"
"github.com/ditatompel/xmr-nodes/internal/database"
"github.com/alexedwards/argon2id"
)
type Admin struct {
Id int `db:"id"`
Username string `db:"username"`
Password string `db:"password"`
LastactiveTs int64 `db:"lastactive_ts"`
CreatedTs int64 `db:"created_ts"`
}
type AdminRepo struct {
db *database.DB
}
type AdminRepository interface {
CreateAdmin(*Admin) (*Admin, error)
Login(username string, password string) (*Admin, error)
}
func NewAdminRepo(db *database.DB) AdminRepository {
return &AdminRepo{db}
}
func (repo *AdminRepo) CreateAdmin(admin *Admin) (*Admin, error) {
if !validUsername(admin.Username) {
return nil, errors.New("username is not valid, must be at least 4 characters long and contain only lowercase letters and numbers")
}
if !strongPassword(admin.Password) {
return nil, errors.New("password is not strong enough, must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, one number and one special character")
}
hash, err := setPassword(admin.Password)
if err != nil {
return nil, err
}
admin.Password = hash
admin.CreatedTs = time.Now().Unix()
if repo.isUsernameExists(admin.Username) {
return nil, errors.New("username already exists")
}
query := `INSERT INTO tbl_admin (username, password, created_ts) VALUES (?, ?, ?)`
_, err = repo.db.Exec(query, admin.Username, admin.Password, admin.CreatedTs)
if err != nil {
return nil, err
}
return admin, nil
}
func (repo *AdminRepo) Login(username, password string) (*Admin, error) {
query := `SELECT id, username, password FROM tbl_admin WHERE username = ? LIMIT 1`
row, err := repo.db.Query(query, username)
if err != nil {
fmt.Println(err)
return nil, err
}
defer row.Close()
admin := Admin{}
if row.Next() {
err = row.Scan(&admin.Id, &admin.Username, &admin.Password)
if err != nil {
fmt.Println(err)
return nil, err
}
} else {
return nil, errors.New("Invalid username or password")
}
match, err := checkPassword(admin.Password, password)
if err != nil {
fmt.Println(err)
return nil, err
}
if !match {
return nil, errors.New("Invalid username or password")
}
update := `UPDATE tbl_admin SET lastactive_ts = ? WHERE id = ?`
_, err = repo.db.Exec(update, time.Now().Unix(), admin.Id)
if err != nil {
fmt.Println(err)
return nil, err
}
return &admin, nil
}
func (repo *AdminRepo) isUsernameExists(username string) bool {
query := `SELECT id FROM tbl_admin WHERE username = ? LIMIT 1`
row, err := repo.db.Query(query, username)
if err != nil {
return false
}
defer row.Close()
return row.Next()
}
func setPassword(password string) (string, error) {
hash, err := argon2id.CreateHash(password, argon2id.DefaultParams)
if err != nil {
return "", err
}
return hash, nil
}
func checkPassword(hash, password string) (bool, error) {
match, err := argon2id.ComparePasswordAndHash(password, hash)
if err != nil {
return false, err
}
return match, nil
}
func strongPassword(password string) bool {
if len(password) < 8 {
return false
}
if !strings.ContainsAny(password, "0123456789") {
return false
}
if !strings.ContainsAny(password, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
return false
}
if !strings.ContainsAny(password, "abcdefghijklmnopqrstuvwxyz") {
return false
}
if !strings.ContainsAny(password, "!@#$%^&*()_+|~-=`{}[]:;<>?,./") {
return false
}
return true
}
// No special character and unicode for username
func validUsername(username string) bool {
if len(username) < 5 || len(username) > 20 {
return false
}
// reject witespace, tabs, newlines, and other special characters
if strings.ContainsAny(username, " \t\n") {
return false
}
// reject unicode
if strings.ContainsAny(username, "^\x00-\x7F") {
return false
}
// reject special characters
if strings.ContainsAny(username, "!@#$%^&*()_+|~-=`{}[]:;<>?,./ ") { // note last blank space
return false
}
if !strings.ContainsAny(username, "abcdefghijklmnopqrstuvwxyz0123456789") {
return false
}
return true
}

112
internal/repo/cron.go Normal file
View file

@ -0,0 +1,112 @@
package repo
import (
"fmt"
"math"
"time"
"github.com/ditatompel/xmr-nodes/internal/database"
)
type CronRepository interface {
RunCronProcess()
Crons() ([]CronTask, error)
}
type CronRepo struct {
db *database.DB
}
type CronTask struct {
Id int `json:"id" db:"id"`
Title string `json:"title" db:"title"`
Slug string `json:"slug" db:"slug"`
Description string `json:"description" db:"description"`
RunEvery int `json:"run_every" db:"run_every"`
LastRun int64 `json:"last_run" db:"last_run"`
NextRun int64 `json:"next_run" db:"next_run"`
RunTime float64 `json:"run_time" db:"run_time"`
CronState int `json:"cron_state" db:"cron_state"`
IsEnabled int `json:"is_enabled" db:"is_enabled"`
}
var rerunTimeout = 300
func NewCron(db *database.DB) CronRepository {
return &CronRepo{db}
}
func (repo *CronRepo) RunCronProcess() {
for {
time.Sleep(60 * time.Second)
fmt.Println("Running cron...")
list, err := repo.queueList()
if err != nil {
fmt.Println("Error parsing to struct:", err)
continue
}
for _, task := range list {
startTime := time.Now()
currentTs := startTime.Unix()
delayedTask := currentTs - task.NextRun
if task.CronState == 1 && delayedTask <= int64(rerunTimeout) {
fmt.Println("SKIP STATE 1:", task.Slug)
continue
}
repo.preRunTask(task.Id, currentTs)
repo.execCron(task.Slug)
runTime := math.Ceil(time.Since(startTime).Seconds()*1000) / 1000
fmt.Println("Runtime:", runTime)
nextRun := currentTs + int64(task.RunEvery)
repo.postRunTask(task.Id, nextRun, runTime)
}
fmt.Println("Cron done!")
}
}
func (repo *CronRepo) Crons() ([]CronTask, error) {
tasks := []CronTask{}
query := `SELECT * FROM tbl_cron`
err := repo.db.Select(&tasks, query)
return tasks, err
}
func (repo *CronRepo) queueList() ([]CronTask, error) {
tasks := []CronTask{}
query := `SELECT id, run_every, last_run, slug, next_run, cron_state FROM tbl_cron
WHERE is_enabled = ? AND next_run <= ?`
err := repo.db.Select(&tasks, query, 1, time.Now().Unix())
return tasks, err
}
func (repo *CronRepo) preRunTask(id int, lastRunTs int64) {
query := `UPDATE tbl_cron SET cron_state = ?, last_run = ? WHERE id = ?`
row, err := repo.db.Query(query, 1, lastRunTs, id)
if err != nil {
fmt.Println("ERROR PRERUN:", err)
}
defer row.Close()
}
func (repo *CronRepo) postRunTask(id int, nextRun int64, runtime float64) {
query := `UPDATE tbl_cron SET cron_state = ?, next_run = ?, run_time = ? WHERE id = ?`
row, err := repo.db.Query(query, 0, nextRun, runtime, id)
if err != nil {
fmt.Println("ERROR PRERUN:", err)
}
defer row.Close()
}
func (repo *CronRepo) execCron(slug string) {
switch slug {
case "something":
fmt.Println("Running task", slug)
// do task
break
}
}

104
internal/repo/prober.go Normal file
View file

@ -0,0 +1,104 @@
package repo
import (
"fmt"
"strings"
"github.com/ditatompel/xmr-nodes/internal/database"
"github.com/google/uuid"
)
type ProberRepository interface {
AddProber(name string) error
Probers(q ProbersQueryParams) (Probers, error)
}
type ProberRepo struct {
db *database.DB
}
type Prober struct {
Id int64 `json:"id" db:"id"`
Name string `json:"name" db:"name"`
ApiKey uuid.UUID `json:"api_key" db:"api_key"`
LastSubmitTs int64 `json:"last_submit_ts" db:"last_submit_ts"`
}
type ProbersQueryParams struct {
Name string
ApiKey string
RowsPerPage int
Page int
}
type Probers struct {
TotalRows int `json:"total_rows"`
RowsPerPage int `json:"rows_per_page"`
CurrentPage int `json:"current_page"`
NextPage int `json:"next_page"`
Items []*Prober `json:"items"`
}
func NewProberRepo(db *database.DB) ProberRepository {
return &ProberRepo{db}
}
func (repo *ProberRepo) AddProber(name string) error {
query := `INSERT INTO tbl_prober (name, api_key, last_submit_ts) VALUES (?, ?, ?)`
_, err := repo.db.Exec(query, name, uuid.New(), 0)
if err != nil {
return err
}
return nil
}
func (repo *ProberRepo) Probers(q ProbersQueryParams) (Probers, error) {
queryParams := []interface{}{}
whereQueries := []string{}
where := ""
if q.Name != "" {
whereQueries = append(whereQueries, "name LIKE ?")
queryParams = append(queryParams, "%"+q.Name+"%")
}
if q.ApiKey != "" {
whereQueries = append(whereQueries, "api_key LIKE ?")
queryParams = append(queryParams, "%"+q.ApiKey+"%")
}
if len(whereQueries) > 0 {
where = "WHERE " + strings.Join(whereQueries, " AND ")
}
probers := Probers{}
queryTotalRows := fmt.Sprintf("SELECT COUNT(id) AS total_rows FROM tbl_prober %s", where)
err := repo.db.QueryRow(queryTotalRows, queryParams...).Scan(&probers.TotalRows)
if err != nil {
return probers, err
}
queryParams = append(queryParams, q.RowsPerPage, (q.Page-1)*q.RowsPerPage)
query := fmt.Sprintf("SELECT id, name, api_key, last_submit_ts FROM tbl_prober %s ORDER BY id DESC LIMIT ? OFFSET ?", where)
row, err := repo.db.Query(query, queryParams...)
if err != nil {
return probers, err
}
defer row.Close()
probers.RowsPerPage = q.RowsPerPage
probers.CurrentPage = q.Page
probers.NextPage = q.Page + 1
for row.Next() {
prober := Prober{}
err = row.Scan(&prober.Id, &prober.Name, &prober.ApiKey, &prober.LastSubmitTs)
if err != nil {
return probers, err
}
probers.Items = append(probers.Items, &prober)
}
return probers, nil
}

7
main.go Normal file
View file

@ -0,0 +1,7 @@
package main
import "github.com/ditatompel/xmr-nodes/cmd"
func main() {
cmd.Execute()
}

14
tools/mysql-dump.sh Executable file
View file

@ -0,0 +1,14 @@
#!/bin/sh
# Dump local dev database structure and required data from specific tables.
# ------------------------------------------------------------------------------
# shellcheck disable=SC2046 # Ignore SC2046: Quote this to prevent word splitting.
SD=$(dirname "$(readlink -f -- "$0")")
cd "$SD" || exit 1 && cd ".." || exit 1
## Structure only dump
mariadb-dump --no-data --skip-comments xmr_nodes | \
sed 's/ AUTO_INCREMENT=[0-9]*//g' > \
"./tools/resources/database/structure.sql"
# vim: set ts=4 sw=4 tw=0 et ft=sh:

View file

@ -0,0 +1,63 @@
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
DROP TABLE IF EXISTS `tbl_admin`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `tbl_admin` (
`id` bigint(30) unsigned NOT NULL AUTO_INCREMENT,
`username` varchar(200) NOT NULL,
`password` varchar(200) NOT NULL,
`lastactive_ts` int(11) unsigned NOT NULL DEFAULT 0,
`created_ts` int(11) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `tbl_cron`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `tbl_cron` (
`id` int(8) unsigned NOT NULL AUTO_INCREMENT,
`title` varchar(200) NOT NULL DEFAULT '',
`slug` varchar(200) NOT NULL DEFAULT '',
`description` varchar(200) DEFAULT NULL,
`run_every` int(8) unsigned NOT NULL DEFAULT 60 COMMENT 'in seconds',
`last_run` bigint(20) unsigned DEFAULT NULL,
`next_run` bigint(20) unsigned DEFAULT NULL,
`run_time` float(7,3) unsigned NOT NULL DEFAULT 0.000,
`cron_state` tinyint(1) unsigned NOT NULL DEFAULT 0,
`is_enabled` int(1) unsigned NOT NULL DEFAULT 1,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `tbl_prober`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `tbl_prober` (
`id` int(9) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
`api_key` varchar(36) NOT NULL,
`last_submit_ts` int(11) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `api_key` (`api_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;