mirror of
https://github.com/khairul169/garage-webui.git
synced 2025-04-28 06:49:32 +07:00
feat: rewrite backend to go
This commit is contained in:
parent
e1d2274bfb
commit
b554cb4dbf
@ -1 +1 @@
|
|||||||
VITE_API_URL=http://localhost:3903
|
VITE_API_URL=http://localhost:3909
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
# App
|
|
||||||
API_BASE_URL=http://localhost:3903
|
|
||||||
CONFIG_PATH=/app/garage/garage.toml
|
|
175
backend/.gitignore
vendored
175
backend/.gitignore
vendored
@ -1,175 +1,2 @@
|
|||||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
|
|
||||||
logs
|
|
||||||
_.log
|
|
||||||
npm-debug.log_
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# Caches
|
|
||||||
|
|
||||||
.cache
|
|
||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
|
||||||
|
|
||||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|
||||||
|
|
||||||
# Runtime data
|
|
||||||
|
|
||||||
pids
|
|
||||||
_.pid
|
|
||||||
_.seed
|
|
||||||
*.pid.lock
|
|
||||||
|
|
||||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
|
||||||
|
|
||||||
lib-cov
|
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
|
||||||
|
|
||||||
coverage
|
|
||||||
*.lcov
|
|
||||||
|
|
||||||
# nyc test coverage
|
|
||||||
|
|
||||||
.nyc_output
|
|
||||||
|
|
||||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
|
||||||
|
|
||||||
.grunt
|
|
||||||
|
|
||||||
# Bower dependency directory (https://bower.io/)
|
|
||||||
|
|
||||||
bower_components
|
|
||||||
|
|
||||||
# node-waf configuration
|
|
||||||
|
|
||||||
.lock-wscript
|
|
||||||
|
|
||||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
|
||||||
|
|
||||||
build/Release
|
|
||||||
|
|
||||||
# Dependency directories
|
|
||||||
|
|
||||||
node_modules/
|
|
||||||
jspm_packages/
|
|
||||||
|
|
||||||
# Snowpack dependency directory (https://snowpack.dev/)
|
|
||||||
|
|
||||||
web_modules/
|
|
||||||
|
|
||||||
# TypeScript cache
|
|
||||||
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# Optional npm cache directory
|
|
||||||
|
|
||||||
.npm
|
|
||||||
|
|
||||||
# Optional eslint cache
|
|
||||||
|
|
||||||
.eslintcache
|
|
||||||
|
|
||||||
# Optional stylelint cache
|
|
||||||
|
|
||||||
.stylelintcache
|
|
||||||
|
|
||||||
# Microbundle cache
|
|
||||||
|
|
||||||
.rpt2_cache/
|
|
||||||
.rts2_cache_cjs/
|
|
||||||
.rts2_cache_es/
|
|
||||||
.rts2_cache_umd/
|
|
||||||
|
|
||||||
# Optional REPL history
|
|
||||||
|
|
||||||
.node_repl_history
|
|
||||||
|
|
||||||
# Output of 'npm pack'
|
|
||||||
|
|
||||||
*.tgz
|
|
||||||
|
|
||||||
# Yarn Integrity file
|
|
||||||
|
|
||||||
.yarn-integrity
|
|
||||||
|
|
||||||
# dotenv environment variable files
|
|
||||||
|
|
||||||
.env
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
.env.local
|
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
|
||||||
|
|
||||||
.parcel-cache
|
|
||||||
|
|
||||||
# Next.js build output
|
|
||||||
|
|
||||||
.next
|
|
||||||
out
|
|
||||||
|
|
||||||
# Nuxt.js build / generate output
|
|
||||||
|
|
||||||
.nuxt
|
|
||||||
dist
|
dist
|
||||||
|
main
|
||||||
# Gatsby files
|
|
||||||
|
|
||||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
|
||||||
|
|
||||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
|
||||||
|
|
||||||
# public
|
|
||||||
|
|
||||||
# vuepress build output
|
|
||||||
|
|
||||||
.vuepress/dist
|
|
||||||
|
|
||||||
# vuepress v2.x temp and cache directory
|
|
||||||
|
|
||||||
.temp
|
|
||||||
|
|
||||||
# Docusaurus cache and generated files
|
|
||||||
|
|
||||||
.docusaurus
|
|
||||||
|
|
||||||
# Serverless directories
|
|
||||||
|
|
||||||
.serverless/
|
|
||||||
|
|
||||||
# FuseBox cache
|
|
||||||
|
|
||||||
.fusebox/
|
|
||||||
|
|
||||||
# DynamoDB Local files
|
|
||||||
|
|
||||||
.dynamodb/
|
|
||||||
|
|
||||||
# TernJS port file
|
|
||||||
|
|
||||||
.tern-port
|
|
||||||
|
|
||||||
# Stores VSCode versions used for testing VSCode extensions
|
|
||||||
|
|
||||||
.vscode-test
|
|
||||||
|
|
||||||
# yarn v2
|
|
||||||
|
|
||||||
.yarn/cache
|
|
||||||
.yarn/unplugged
|
|
||||||
.yarn/build-state.yml
|
|
||||||
.yarn/install-state.gz
|
|
||||||
.pnp.*
|
|
||||||
|
|
||||||
# IntelliJ based IDEs
|
|
||||||
.idea
|
|
||||||
|
|
||||||
# Finder (MacOS) folder config
|
|
||||||
.DS_Store
|
|
||||||
|
6
backend/Makefile
Normal file
6
backend/Makefile
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
build:
|
||||||
|
go build -o main -tags="prod" main.go
|
||||||
|
|
||||||
|
run:
|
||||||
|
go run main.go
|
@ -1,15 +0,0 @@
|
|||||||
# backend
|
|
||||||
|
|
||||||
To install dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun install
|
|
||||||
```
|
|
||||||
|
|
||||||
To run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run index.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
This project was created using `bun init` in bun v1.1.18. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
|
|
5
backend/go.mod
Normal file
5
backend/go.mod
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
module khairul169/garage-webui
|
||||||
|
|
||||||
|
go 1.22.5
|
||||||
|
|
||||||
|
require github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
16
backend/go.sum
Normal file
16
backend/go.sum
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
@ -1,83 +0,0 @@
|
|||||||
import { config } from "./garage";
|
|
||||||
|
|
||||||
type FetchOptions = Omit<RequestInit, "headers" | "body"> & {
|
|
||||||
params?: Record<string, any>;
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
body?: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
const adminPort = config?.admin?.api_bind_addr?.split(":").pop();
|
|
||||||
const adminAddr =
|
|
||||||
import.meta.env.API_BASE_URL ||
|
|
||||||
config?.rpc_public_addr?.split(":")[0] + ":" + adminPort ||
|
|
||||||
"";
|
|
||||||
|
|
||||||
export const API_BASE_URL =
|
|
||||||
!adminAddr.startsWith("http") && !adminAddr.startsWith("https")
|
|
||||||
? `http://${adminAddr}`
|
|
||||||
: adminAddr;
|
|
||||||
|
|
||||||
export const API_ADMIN_KEY =
|
|
||||||
import.meta.env.API_ADMIN_KEY || config?.admin?.admin_token;
|
|
||||||
|
|
||||||
const api = {
|
|
||||||
async fetch<T = any>(url: string, options?: Partial<FetchOptions>) {
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
Authorization: `Bearer ${API_ADMIN_KEY}`,
|
|
||||||
};
|
|
||||||
const _url = new URL(API_BASE_URL + url);
|
|
||||||
|
|
||||||
if (options?.params) {
|
|
||||||
Object.entries(options.params).forEach(([key, value]) => {
|
|
||||||
_url.searchParams.set(key, String(value));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof options?.body === "object" &&
|
|
||||||
!(options.body instanceof FormData)
|
|
||||||
) {
|
|
||||||
options.body = JSON.stringify(options.body);
|
|
||||||
headers["Content-Type"] = "application/json";
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(_url, {
|
|
||||||
...options,
|
|
||||||
headers: { ...headers, ...(options?.headers || {}) },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = new Error(res.statusText);
|
|
||||||
(err as any).status = res.status;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isJson = res.headers
|
|
||||||
.get("Content-Type")
|
|
||||||
?.includes("application/json");
|
|
||||||
|
|
||||||
if (isJson) {
|
|
||||||
const json = (await res.json()) as T;
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = await res.text();
|
|
||||||
return text as unknown as T;
|
|
||||||
},
|
|
||||||
|
|
||||||
async get<T = any>(url: string, options?: Partial<FetchOptions>) {
|
|
||||||
return this.fetch<T>(url, {
|
|
||||||
...options,
|
|
||||||
method: "GET",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async post<T = any>(url: string, options?: Partial<FetchOptions>) {
|
|
||||||
return this.fetch<T>(url, {
|
|
||||||
...options,
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default api;
|
|
@ -1,2 +0,0 @@
|
|||||||
//
|
|
||||||
export const __PROD = import.meta.env.NODE_ENV === "production";
|
|
@ -1,16 +0,0 @@
|
|||||||
import type { Config } from "../types/garage";
|
|
||||||
import { readTomlFile } from "./utils";
|
|
||||||
|
|
||||||
const CONFIG_PATH = process.env.CONFIG_PATH || "/etc/garage.toml";
|
|
||||||
|
|
||||||
export const config = await readTomlFile<Config>(CONFIG_PATH);
|
|
||||||
|
|
||||||
if (!config?.rpc_public_addr) {
|
|
||||||
throw new Error(
|
|
||||||
"Cannot load garage config! Missing `rpc_public_addr` in config file."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!config?.admin?.admin_token) {
|
|
||||||
throw new Error("Missing `admin.admin_token` in config.");
|
|
||||||
}
|
|
@ -1,36 +0,0 @@
|
|||||||
import type { Context } from "hono";
|
|
||||||
import { API_ADMIN_KEY, API_BASE_URL } from "./api";
|
|
||||||
|
|
||||||
export const proxyApi = async (c: Context) => {
|
|
||||||
const url = new URL(c.req.url);
|
|
||||||
const pathname = url.pathname.replace(/\/api\//, "/");
|
|
||||||
const reqUrl = new URL(API_BASE_URL + pathname + url.search);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const headers = c.req.raw.headers;
|
|
||||||
let body: BodyInit | ReadableStream<Uint8Array> | null = c.req.raw.body;
|
|
||||||
|
|
||||||
headers.set("authorization", `Bearer ${API_ADMIN_KEY}`);
|
|
||||||
|
|
||||||
if (headers.get("content-type")?.includes("application/json")) {
|
|
||||||
const json = await c.req.json();
|
|
||||||
body = JSON.stringify(json);
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(reqUrl, {
|
|
||||||
...c.req.raw,
|
|
||||||
method: c.req.method,
|
|
||||||
headers,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
return res;
|
|
||||||
} catch (err) {
|
|
||||||
return c.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: (err as Error)?.message || "Server error",
|
|
||||||
},
|
|
||||||
500
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
@ -1,14 +0,0 @@
|
|||||||
import toml from "toml";
|
|
||||||
|
|
||||||
export const readTomlFile = async <T = any>(path?: string | null) => {
|
|
||||||
if (!path) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = Bun.file(path);
|
|
||||||
if (!(await file.exists())) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return toml.parse(await file.text()) as T;
|
|
||||||
};
|
|
32
backend/main.go
Normal file
32
backend/main.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"khairul169/garage-webui/router"
|
||||||
|
"khairul169/garage-webui/ui"
|
||||||
|
"khairul169/garage-webui/utils"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := utils.Garage.LoadConfig(); err != nil {
|
||||||
|
log.Fatal("Failed to load config! ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
http.HandleFunc("/api/config", router.GetConfig)
|
||||||
|
http.HandleFunc("/api/buckets", router.GetAllBuckets)
|
||||||
|
http.HandleFunc("/api/*", router.ProxyHandler)
|
||||||
|
|
||||||
|
ui.ServeUI()
|
||||||
|
|
||||||
|
host := utils.GetEnv("HOST", "0.0.0.0")
|
||||||
|
port := utils.GetEnv("PORT", "3908")
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%s", host, port)
|
||||||
|
log.Printf("Starting server on http://%s", addr)
|
||||||
|
|
||||||
|
if err := http.ListenAndServe(addr, nil); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
@ -1,37 +0,0 @@
|
|||||||
import { Hono } from "hono";
|
|
||||||
import { logger } from "hono/logger";
|
|
||||||
import { serveStatic } from "hono/bun";
|
|
||||||
import router from "./routes";
|
|
||||||
import { __PROD } from "./lib/consts";
|
|
||||||
|
|
||||||
const HOST = import.meta.env.HOST || "0.0.0.0";
|
|
||||||
const PORT = Number(import.meta.env.PORT) || 3909;
|
|
||||||
const DIST_ROOT = import.meta.env.DIST_ROOT || "./dist";
|
|
||||||
|
|
||||||
const app = new Hono();
|
|
||||||
|
|
||||||
app.use(logger());
|
|
||||||
|
|
||||||
// API router
|
|
||||||
app.route("/api", router);
|
|
||||||
|
|
||||||
if (__PROD) {
|
|
||||||
// Serve client dist
|
|
||||||
app.use(serveStatic({ root: DIST_ROOT }));
|
|
||||||
app.use(async (c, next) => {
|
|
||||||
try {
|
|
||||||
const file = Bun.file(DIST_ROOT + "/index.html");
|
|
||||||
return c.html(await file.text());
|
|
||||||
} catch (err) {
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Listening on http://${HOST}:${PORT}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
fetch: app.fetch,
|
|
||||||
hostname: HOST,
|
|
||||||
port: PORT,
|
|
||||||
};
|
|
@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "backend",
|
|
||||||
"module": "main.ts",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "bun --watch main.ts",
|
|
||||||
"build": "bun build main.ts --minify --sourcemap --outdir ./dist",
|
|
||||||
"start": "NODE_ENV=production bun run ./dist/main.js"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/bun": "latest"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"typescript": "^5.0.0"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"hono": "^4.5.5",
|
|
||||||
"toml": "^3.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
79
backend/pnpm-lock.yaml
generated
79
backend/pnpm-lock.yaml
generated
@ -1,79 +0,0 @@
|
|||||||
lockfileVersion: '9.0'
|
|
||||||
|
|
||||||
settings:
|
|
||||||
autoInstallPeers: true
|
|
||||||
excludeLinksFromLockfile: false
|
|
||||||
|
|
||||||
importers:
|
|
||||||
|
|
||||||
.:
|
|
||||||
dependencies:
|
|
||||||
hono:
|
|
||||||
specifier: ^4.5.5
|
|
||||||
version: 4.5.5
|
|
||||||
toml:
|
|
||||||
specifier: ^3.0.0
|
|
||||||
version: 3.0.0
|
|
||||||
typescript:
|
|
||||||
specifier: ^5.0.0
|
|
||||||
version: 5.5.4
|
|
||||||
devDependencies:
|
|
||||||
'@types/bun':
|
|
||||||
specifier: latest
|
|
||||||
version: 1.1.6
|
|
||||||
|
|
||||||
packages:
|
|
||||||
|
|
||||||
'@types/bun@1.1.6':
|
|
||||||
resolution: {integrity: sha512-uJgKjTdX0GkWEHZzQzFsJkWp5+43ZS7HC8sZPFnOwnSo1AsNl2q9o2bFeS23disNDqbggEgyFkKCHl/w8iZsMA==}
|
|
||||||
|
|
||||||
'@types/node@20.12.14':
|
|
||||||
resolution: {integrity: sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg==}
|
|
||||||
|
|
||||||
'@types/ws@8.5.12':
|
|
||||||
resolution: {integrity: sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==}
|
|
||||||
|
|
||||||
bun-types@1.1.17:
|
|
||||||
resolution: {integrity: sha512-Z4+OplcSd/YZq7ZsrfD00DKJeCwuNY96a1IDJyR73+cTBaFIS7SC6LhpY/W3AMEXO9iYq5NJ58WAwnwL1p5vKg==}
|
|
||||||
|
|
||||||
hono@4.5.5:
|
|
||||||
resolution: {integrity: sha512-fXBXHqaVfimWofbelLXci8pZyIwBMkDIwCa4OwZvK+xVbEyYLELVP4DfbGaj1aEM6ZY3hHgs4qLvCO2ChkhgQw==}
|
|
||||||
engines: {node: '>=16.0.0'}
|
|
||||||
|
|
||||||
toml@3.0.0:
|
|
||||||
resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==}
|
|
||||||
|
|
||||||
typescript@5.5.4:
|
|
||||||
resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==}
|
|
||||||
engines: {node: '>=14.17'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
undici-types@5.26.5:
|
|
||||||
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
|
|
||||||
|
|
||||||
snapshots:
|
|
||||||
|
|
||||||
'@types/bun@1.1.6':
|
|
||||||
dependencies:
|
|
||||||
bun-types: 1.1.17
|
|
||||||
|
|
||||||
'@types/node@20.12.14':
|
|
||||||
dependencies:
|
|
||||||
undici-types: 5.26.5
|
|
||||||
|
|
||||||
'@types/ws@8.5.12':
|
|
||||||
dependencies:
|
|
||||||
'@types/node': 20.12.14
|
|
||||||
|
|
||||||
bun-types@1.1.17:
|
|
||||||
dependencies:
|
|
||||||
'@types/node': 20.12.14
|
|
||||||
'@types/ws': 8.5.12
|
|
||||||
|
|
||||||
hono@4.5.5: {}
|
|
||||||
|
|
||||||
toml@3.0.0: {}
|
|
||||||
|
|
||||||
typescript@5.5.4: {}
|
|
||||||
|
|
||||||
undici-types@5.26.5: {}
|
|
51
backend/router/buckets.go
Normal file
51
backend/router/buckets.go
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"khairul169/garage-webui/schema"
|
||||||
|
"khairul169/garage-webui/utils"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetAllBuckets(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := utils.Garage.Fetch("/v1/bucket?list", &utils.FetchOptions{})
|
||||||
|
if err != nil {
|
||||||
|
utils.ResponseError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var buckets []schema.GetBucketsRes
|
||||||
|
if err := json.Unmarshal(body, &buckets); err != nil {
|
||||||
|
utils.ResponseError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := make(chan schema.Bucket, len(buckets))
|
||||||
|
|
||||||
|
for _, bucket := range buckets {
|
||||||
|
go func() {
|
||||||
|
body, err := utils.Garage.Fetch(fmt.Sprintf("/v1/bucket?id=%s", bucket.ID), &utils.FetchOptions{})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
ch <- schema.Bucket{ID: bucket.ID, GlobalAliases: bucket.GlobalAliases}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var bucket schema.Bucket
|
||||||
|
if err := json.Unmarshal(body, &bucket); err != nil {
|
||||||
|
ch <- schema.Bucket{ID: bucket.ID, GlobalAliases: bucket.GlobalAliases}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ch <- bucket
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
res := make([]schema.Bucket, 0, len(buckets))
|
||||||
|
for i := 0; i < len(buckets); i++ {
|
||||||
|
res = append(res, <-ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.ResponseSuccess(w, res)
|
||||||
|
}
|
11
backend/router/config.go
Normal file
11
backend/router/config.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"khairul169/garage-webui/utils"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
config := utils.Garage.Config
|
||||||
|
utils.ResponseSuccess(w, config)
|
||||||
|
}
|
28
backend/router/proxy.go
Normal file
28
backend/router/proxy.go
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"khairul169/garage-webui/utils"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ProxyHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
target, err := url.Parse(utils.Garage.GetAdminEndpoint())
|
||||||
|
if err != nil {
|
||||||
|
utils.ResponseError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := &httputil.ReverseProxy{
|
||||||
|
Rewrite: func(r *httputil.ProxyRequest) {
|
||||||
|
r.SetURL(target)
|
||||||
|
r.Out.URL.Path = strings.TrimPrefix(r.In.URL.Path, "/api")
|
||||||
|
r.Out.Header.Set("Authorization", fmt.Sprintf("Bearer %s", utils.Garage.GetAdminKey()))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy.ServeHTTP(w, r)
|
||||||
|
}
|
@ -1,19 +0,0 @@
|
|||||||
import { Hono } from "hono";
|
|
||||||
import api from "../lib/api";
|
|
||||||
|
|
||||||
export const buckets = new Hono()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all buckets
|
|
||||||
*/
|
|
||||||
.get("/", async (c) => {
|
|
||||||
const data = await api.get("/v1/bucket?list");
|
|
||||||
|
|
||||||
const buckets = await Promise.all(
|
|
||||||
data.map(async (bucket: any) => {
|
|
||||||
return api.get("/v1/bucket", { params: { id: bucket.id } });
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return c.json(buckets);
|
|
||||||
});
|
|
@ -1,21 +0,0 @@
|
|||||||
import { Hono } from "hono";
|
|
||||||
import { config } from "../lib/garage";
|
|
||||||
|
|
||||||
export const configRoute = new Hono()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get garage config
|
|
||||||
*/
|
|
||||||
.get("/", async (c) => {
|
|
||||||
const data = {
|
|
||||||
...(config || {}),
|
|
||||||
rpc_secret: undefined,
|
|
||||||
admin: {
|
|
||||||
...(config?.admin || {}),
|
|
||||||
admin_token: undefined,
|
|
||||||
metrics_token: undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return c.json(data);
|
|
||||||
});
|
|
@ -1,13 +0,0 @@
|
|||||||
import { Hono } from "hono";
|
|
||||||
import { buckets } from "./buckets";
|
|
||||||
import { configRoute } from "./config";
|
|
||||||
import { proxyApi } from "../lib/proxy-api";
|
|
||||||
|
|
||||||
const router = new Hono()
|
|
||||||
//
|
|
||||||
.route("/config", configRoute)
|
|
||||||
.route("/buckets", buckets)
|
|
||||||
|
|
||||||
.all("*", proxyApi);
|
|
||||||
|
|
||||||
export default router;
|
|
45
backend/schema/bucket.go
Normal file
45
backend/schema/bucket.go
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package schema
|
||||||
|
|
||||||
|
type GetBucketsRes struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
GlobalAliases []string `json:"globalAliases"`
|
||||||
|
LocalAliases []string `json:"localAliases"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bucket struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
GlobalAliases []string `json:"globalAliases"`
|
||||||
|
WebsiteAccess bool `json:"websiteAccess"`
|
||||||
|
WebsiteConfig WebsiteConfig `json:"websiteConfig"`
|
||||||
|
Keys []KeyElement `json:"keys"`
|
||||||
|
Objects int64 `json:"objects"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
UnfinishedUploads int64 `json:"unfinishedUploads"`
|
||||||
|
UnfinishedMultipartUploads int64 `json:"unfinishedMultipartUploads"`
|
||||||
|
UnfinishedMultipartUploadParts int64 `json:"unfinishedMultipartUploadParts"`
|
||||||
|
UnfinishedMultipartUploadBytes int64 `json:"unfinishedMultipartUploadBytes"`
|
||||||
|
Quotas Quotas `json:"quotas"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeyElement struct {
|
||||||
|
AccessKeyID string `json:"accessKeyId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Permissions Permissions `json:"permissions"`
|
||||||
|
BucketLocalAliases []interface{} `json:"bucketLocalAliases"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Permissions struct {
|
||||||
|
Read bool `json:"read"`
|
||||||
|
Write bool `json:"write"`
|
||||||
|
Owner bool `json:"owner"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Quotas struct {
|
||||||
|
MaxSize int64 `json:"maxSize"`
|
||||||
|
MaxObjects int64 `json:"maxObjects"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebsiteConfig struct {
|
||||||
|
IndexDocument string `json:"indexDocument"`
|
||||||
|
ErrorDocument string `json:"errorDocument"`
|
||||||
|
}
|
34
backend/schema/config.go
Normal file
34
backend/schema/config.go
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
package schema
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
CompressionLevel int64 `json:"compression_level" toml:"compression_level"`
|
||||||
|
DataDir string `json:"data_dir" toml:"data_dir"`
|
||||||
|
DBEngine string `json:"db_engine" toml:"db_engine"`
|
||||||
|
MetadataAutoSnapshotInterval string `json:"metadata_auto_snapshot_interval" toml:"metadata_auto_snapshot_interval"`
|
||||||
|
MetadataDir string `json:"metadata_dir" toml:"metadata_dir"`
|
||||||
|
ReplicationFactor int64 `json:"replication_factor" toml:"replication_factor"`
|
||||||
|
RPCBindAddr string `json:"rpc_bind_addr" toml:"rpc_bind_addr"`
|
||||||
|
RPCPublicAddr string `json:"rpc_public_addr" toml:"rpc_public_addr"`
|
||||||
|
RPCSecret string `json:"rpc_secret" toml:"rpc_secret"`
|
||||||
|
Admin Admin `json:"admin" toml:"admin"`
|
||||||
|
S3API S3API `json:"s3_api" toml:"s3_api"`
|
||||||
|
S3Web S3Web `json:"s3_web" toml:"s3_web"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Admin struct {
|
||||||
|
AdminToken string `json:"admin_token" toml:"admin_token"`
|
||||||
|
APIBindAddr string `json:"api_bind_addr" toml:"api_bind_addr"`
|
||||||
|
MetricsToken string `json:"metrics_token" toml:"metrics_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type S3API struct {
|
||||||
|
APIBindAddr string `json:"api_bind_addr" toml:"api_bind_addr"`
|
||||||
|
RootDomain string `json:"root_domain" toml:"root_domain"`
|
||||||
|
S3Region string `json:"s3_region" toml:"s3_region"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type S3Web struct {
|
||||||
|
BindAddr string `json:"bind_addr" toml:"bind_addr"`
|
||||||
|
Index string `json:"index" toml:"index"`
|
||||||
|
RootDomain string `json:"root_domain" toml:"root_domain"`
|
||||||
|
}
|
@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
// Enable latest features
|
|
||||||
"lib": ["ESNext", "DOM"],
|
|
||||||
"target": "ESNext",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"allowJs": true,
|
|
||||||
|
|
||||||
// Bundler mode
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"noEmit": true,
|
|
||||||
|
|
||||||
// Best practices
|
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
|
|
||||||
// Some stricter flags (disabled by default)
|
|
||||||
"noUnusedLocals": false,
|
|
||||||
"noUnusedParameters": false,
|
|
||||||
"noPropertyAccessFromIndexSignature": false
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,32 +0,0 @@
|
|||||||
export type Config = {
|
|
||||||
metadata_dir: string;
|
|
||||||
data_dir: string;
|
|
||||||
db_engine: string;
|
|
||||||
metadata_auto_snapshot_interval: string;
|
|
||||||
replication_factor: number;
|
|
||||||
compression_level: number;
|
|
||||||
rpc_bind_addr: string;
|
|
||||||
rpc_public_addr: string;
|
|
||||||
rpc_secret: string;
|
|
||||||
s3_api?: S3API;
|
|
||||||
s3_web?: S3Web;
|
|
||||||
admin?: Admin;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Admin = {
|
|
||||||
api_bind_addr: string;
|
|
||||||
admin_token: string;
|
|
||||||
metrics_token: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type S3API = {
|
|
||||||
s3_region: string;
|
|
||||||
api_bind_addr: string;
|
|
||||||
root_domain: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type S3Web = {
|
|
||||||
bind_addr: string;
|
|
||||||
root_domain: string;
|
|
||||||
index: string;
|
|
||||||
};
|
|
6
backend/ui/ui.go
Normal file
6
backend/ui/ui.go
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
//go:build !prod
|
||||||
|
// +build !prod
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
func ServeUI() {}
|
34
backend/ui/ui_prod.go
Normal file
34
backend/ui/ui_prod.go
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
//go:build prod
|
||||||
|
// +build prod
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed dist
|
||||||
|
var embeddedFs embed.FS
|
||||||
|
|
||||||
|
func ServeUI() {
|
||||||
|
distFs, _ := fs.Sub(embeddedFs, "dist")
|
||||||
|
fileServer := http.FileServer(http.FS(distFs))
|
||||||
|
|
||||||
|
http.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_path := path.Clean(r.URL.Path)[1:]
|
||||||
|
|
||||||
|
// Rewrite non-existing paths to index.html
|
||||||
|
if _, err := fs.Stat(distFs, _path); err != nil {
|
||||||
|
index, _ := fs.ReadFile(distFs, "index.html")
|
||||||
|
w.Header().Add("Content-Type", "text/html")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(index)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileServer.ServeHTTP(w, r)
|
||||||
|
}))
|
||||||
|
}
|
148
backend/utils/garage.go
Normal file
148
backend/utils/garage.go
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"khairul169/garage-webui/schema"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pelletier/go-toml/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type garage struct {
|
||||||
|
Config schema.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
var Garage = &garage{}
|
||||||
|
|
||||||
|
func (g *garage) LoadConfig() error {
|
||||||
|
path := GetEnv("CONFIG_PATH", "/etc/garage/garage.toml")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg schema.Config
|
||||||
|
err = toml.Unmarshal(data, &cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Config = cfg
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *garage) GetAdminEndpoint() string {
|
||||||
|
endpoint := os.Getenv("API_BASE_URL")
|
||||||
|
if len(endpoint) > 0 {
|
||||||
|
return endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
host := strings.Split(g.Config.RPCPublicAddr, ":")[0]
|
||||||
|
port := LastString(strings.Split(g.Config.Admin.APIBindAddr, ":"))
|
||||||
|
|
||||||
|
endpoint = fmt.Sprintf("%s:%s", host, port)
|
||||||
|
if !strings.HasPrefix(endpoint, "http") {
|
||||||
|
endpoint = fmt.Sprintf("http://%s", endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
return endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *garage) GetAdminKey() string {
|
||||||
|
key := os.Getenv("API_ADMIN_KEY")
|
||||||
|
if len(key) > 0 {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return g.Config.Admin.AdminToken
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchOptions struct {
|
||||||
|
Method string
|
||||||
|
Params map[string]string
|
||||||
|
Body interface{}
|
||||||
|
Headers map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *garage) Fetch(url string, options *FetchOptions) ([]byte, error) {
|
||||||
|
var reqBody io.Reader
|
||||||
|
reqUrl := fmt.Sprintf("%s%s", g.GetAdminEndpoint(), url)
|
||||||
|
method := http.MethodGet
|
||||||
|
|
||||||
|
if len(options.Method) > 0 {
|
||||||
|
method = options.Method
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.Body != nil {
|
||||||
|
body, err := json.Marshal(options.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reqBody = bytes.NewBuffer(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(method, reqUrl, reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.Params != nil {
|
||||||
|
q := req.URL.Query()
|
||||||
|
for k, v := range options.Params {
|
||||||
|
q.Add(k, v)
|
||||||
|
}
|
||||||
|
req.URL.RawQuery = q.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add auth token
|
||||||
|
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", g.GetAdminKey()))
|
||||||
|
|
||||||
|
if options.Headers != nil {
|
||||||
|
for k, v := range options.Headers {
|
||||||
|
req.Header.Add(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if res.Body != nil {
|
||||||
|
defer res.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.StatusCode != 200 {
|
||||||
|
body, err := io.ReadAll(res.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var data map[string]interface{}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
message := fmt.Sprintf("unexpected status code: %d", res.StatusCode)
|
||||||
|
if data["message"] != nil {
|
||||||
|
message = fmt.Sprintf("%v", data["message"])
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(res.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return body, nil
|
||||||
|
}
|
30
backend/utils/utils.go
Normal file
30
backend/utils/utils.go
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetEnv(key, defaultValue string) string {
|
||||||
|
value := os.Getenv(key)
|
||||||
|
if len(value) == 0 {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func LastString(str []string) string {
|
||||||
|
return str[len(str)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResponseError(w http.ResponseWriter, err error) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResponseSuccess(w http.ResponseWriter, data interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(data)
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user