diff --git a/.air.toml b/.air.toml index 1b8c09a..619e647 100644 --- a/.air.toml +++ b/.air.toml @@ -7,7 +7,7 @@ tmp_dir = "tmp" bin = "./tmp/main" cmd = "go build -o ./tmp/main ./cmd/main.go" delay = 1000 - exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_dir = ["assets", "tmp", "vendor", "testdata", "web", "build"] exclude_file = [] exclude_regex = ["_test.go"] exclude_unchanged = false diff --git a/.gitignore b/.gitignore index d7fbdbd..6e2a9a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ tmp -secret.sh \ No newline at end of file +secret.sh +build diff --git a/Dockerfile b/Dockerfile index b2451ac..68e7d0e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,15 +16,31 @@ COPY . ./ RUN go build -o /server ./cmd/main.go +## +## Build UI +## + +FROM oven/bun:1.0 AS ui + +WORKDIR /app + +COPY web ./ + +RUN bun install + +RUN bun run build ## ## Deploy ## -FROM gcr.io/distroless/base-debian12:nonroot +FROM busybox:1.35.0-uclibc AS deploy WORKDIR / +RUN mkdir -p /web + COPY --from=build /server . +COPY --from=ui /app/build /web/build EXPOSE 16321 EXPOSE 6321 diff --git a/Makefile b/Makefile index 0be8b57..78518e4 100644 --- a/Makefile +++ b/Makefile @@ -7,3 +7,9 @@ tidy: lint: @echo "Running linter..." @golangci-lint run + +ui: + @cd web && bun run build + +docker: + @docker build -t quickmq:latest . \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go index f2435d5..ae55bdc 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -7,18 +7,29 @@ import ( "time" "github.com/ochom/gutils/logs" + "github.com/ochom/quickmq/src/api" "github.com/ochom/quickmq/src/app" ) func main() { - svr := app.New() - port := ":16321" + coreServer := app.New() + webServer := api.New() + + // run core go func() { - if err := svr.Listen(port); err != nil { + if err := coreServer.Listen(":6321"); err != nil { panic(err) } }() + // run api and web + go func() { + if err := webServer.Listen(":16321"); err != nil { + panic(err) + } + }() + + // go run consumer daemon go func() { stopSignal := make(chan bool, 1) logs.Info("starting consumers daemon") @@ -34,7 +45,13 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - if err := svr.ShutdownWithContext(ctx); err != nil { + // shutdown core server + if err := coreServer.ShutdownWithContext(ctx); err != nil { + panic(err) + } + + // shutdown api server + if err := webServer.ShutdownWithContext(ctx); err != nil { panic(err) } diff --git a/go.mod b/go.mod index a0b67ae..7a02b8a 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( require ( github.com/andybalholm/brotli v1.1.0 // indirect github.com/gofiber/utils/v2 v2.0.0-beta.4 // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/google/uuid v1.6.0 // indirect github.com/joho/godotenv v1.5.1 // indirect github.com/klauspost/compress v1.17.9 // indirect diff --git a/go.sum b/go.sum index 7af6623..66b2c15 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/gofiber/fiber/v3 v3.0.0-beta.2 h1:mVVgt8PTaHGup3NGl/+7U7nEoZaXJ5OComV github.com/gofiber/fiber/v3 v3.0.0-beta.2/go.mod h1:w7sdfTY0okjZ1oVH6rSOGvuACUIt0By1iK0HKUb3uqM= github.com/gofiber/utils/v2 v2.0.0-beta.4 h1:1gjbVFFwVwUb9arPcqiB6iEjHBwo7cHsyS41NeIW3co= github.com/gofiber/utils/v2 v2.0.0-beta.4/go.mod h1:sdRsPU1FXX6YiDGGxd+q2aPJRMzpsxdzCXo9dz+xtOY= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= diff --git a/src/api/app.go b/src/api/app.go new file mode 100644 index 0000000..83b59af --- /dev/null +++ b/src/api/app.go @@ -0,0 +1,21 @@ +package api + +import ( + "github.com/gofiber/fiber/v3" +) + +func New() *fiber.App { + app := fiber.New() + + // rest apis + app.Post("/login", login) + app.Get("/user", loadUSer) + + // serve other static files + app.Static("/", "web/build") + app.Get("*", func(c fiber.Ctx) error { + return c.SendFile("web/build/index.html") + }) + + return app +} diff --git a/src/api/auth.go b/src/api/auth.go new file mode 100644 index 0000000..5da3dbb --- /dev/null +++ b/src/api/auth.go @@ -0,0 +1,61 @@ +package api + +import ( + "time" + + "github.com/gofiber/fiber/v3" + "github.com/ochom/gutils/auth" + "github.com/ochom/gutils/env" + "github.com/ochom/gutils/uuid" +) + +func login(c fiber.Ctx) error { + var data struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := c.Bind().Body(&data); err != nil { + return err + } + + username := env.Get("QUICK_MQ_USERNAME", "admin") + password := env.Get("QUICK_MQ_PASSWORD", "admin") + + if data.Username != username { + return c.Status(400).JSON(fiber.Map{"status": "error", "message": "Invalid username"}) + } + + if data.Password != password { + return c.Status(400).JSON(fiber.Map{"status": "error", "message": "Invalid password"}) + } + + token, err := auth.GenerateAuthTokens(map[string]string{"user": "admin", "session_id": uuid.New()}) + if err != nil { + return err + } + + // Set a cookie + cookie := fiber.Cookie{ + Name: "jwt", + Value: token["token"], + Expires: time.Now().Add(time.Hour * 24), + HTTPOnly: true, + } + c.Cookie(&cookie) + + return c.JSON(fiber.Map{"status": "success", "message": "Logged in", "username": "admin"}) +} + +func loadUSer(c fiber.Ctx) error { + token := c.Cookies("jwt") + claims, err := auth.GetAuthClaims(token) + if err != nil { + return c.Status(401).JSON(fiber.Map{"status": "error", "message": "Unauthorized", "data": err.Error()}) + } + + if claims["user"] != "admin" { + return c.Status(401).JSON(fiber.Map{"status": "error", "message": "Unauthorized"}) + } + + return c.JSON(fiber.Map{"status": "success", "message": "Logged in", "username": "admin"}) +} diff --git a/web/.eslintrc.cjs b/web/.eslintrc.cjs new file mode 100644 index 0000000..73cc6ce --- /dev/null +++ b/web/.eslintrc.cjs @@ -0,0 +1,14 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: ['eslint:recommended', 'plugin:react/recommended', 'plugin:react/jsx-runtime', 'plugin:react-hooks/recommended'], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, + settings: { react: { version: '18.2' } }, + plugins: ['react-refresh'], + rules: { + 'react/jsx-no-target-blank': 'off', + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + 'react/prop-types': 'off' + } +}; diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web/.prettierrc.json b/web/.prettierrc.json new file mode 100644 index 0000000..e7fe6d5 --- /dev/null +++ b/web/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "jsxSingleQuote": true, + "arrowParens": "avoid", + "trailingComma": "none", + "printWidth": 200 +} \ No newline at end of file diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..f768e33 --- /dev/null +++ b/web/README.md @@ -0,0 +1,8 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh diff --git a/web/bun.lockb b/web/bun.lockb new file mode 100755 index 0000000..3de9440 Binary files /dev/null and b/web/bun.lockb differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..f853161 --- /dev/null +++ b/web/index.html @@ -0,0 +1,22 @@ + + + + + + + + Vite + React + + + + + + + +
+ + + + \ No newline at end of file diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..0de7115 --- /dev/null +++ b/web/package.json @@ -0,0 +1,26 @@ +{ + "name": "web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@vitejs/plugin-react": "^4.2.1", + "eslint": "^8.57.0", + "eslint-plugin-react": "^7.34.1", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.6", + "vite": "^5.2.0" + } +} diff --git a/web/public/vite.svg b/web/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/web/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/App.css b/web/src/App.css new file mode 100644 index 0000000..df019d6 --- /dev/null +++ b/web/src/App.css @@ -0,0 +1,5 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; +} \ No newline at end of file diff --git a/web/src/App.jsx b/web/src/App.jsx new file mode 100644 index 0000000..d3bcc80 --- /dev/null +++ b/web/src/App.jsx @@ -0,0 +1,77 @@ +import { useRef, useState } from 'react'; +import './App.css'; +import './Login.css'; +import { useSession } from './hooks/useSession'; +import { baseUrl } from './constants/api'; + +function Login() { + const { setUser } = useSession(); + const usernameRef = useRef(null); + const passwordRef = useRef(null); + const [loading, setLoading] = useState(false); + + const submit = async e => { + e.preventDefault(); + setLoading(true); + try { + const res = await fetch(`${baseUrl}/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + username: usernameRef.current.value, + password: passwordRef.current.value + }) + }); + + if (res.status !== 200) { + throw new Error('Invalid username or password'); + } + + const data = await res.json(); + setUser(data); + } catch (error) { + alert(error?.message); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ QuickMQ +
+
+
+ + +
+
+ + +
+
+ +
+
+
+ ); +} + +function App() { + const { user } = useSession(); + + if (!user) return ; + + return ( +
+

Dashboard

+
+ ); +} + +export default App; diff --git a/web/src/Login.css b/web/src/Login.css new file mode 100644 index 0000000..0ab888f --- /dev/null +++ b/web/src/Login.css @@ -0,0 +1,82 @@ +:root { + --primary-color: #3f51b5; + --secondary-color: #fd00db; + --error: #ff1100; + --success: #4caf50; + --warning: #ff9800; + --info: #2196f3; + --white: #fff; + --black: #000; + --grey: #b6b6b6; +} + +* { + box-sizing: border-box; + font-family: 'Poppins', sans-serif; +} + +body { + margin: 0; + padding: 0; + display: flex; + background: var(--white); +} + +.login-box { + display: flex; + flex-direction: column; + margin: 100px auto 0; + width: 400px; + border: 1px solid var(--grey); + border-radius: 15px; + padding: 30px; +} + +.lobo-box { + width: 100%; + text-align: center; + font-size: 30px; + font-weight: 600; +} + +.form-area { + display: flex; + flex-direction: column; +} + +.input-area { + display: flex; + flex-direction: column; + margin-bottom: 15px; +} + +.input-area label { + margin-bottom: 5px; +} + +.input-area input { + padding: 10px; + border: 1px solid var(--grey); + border-radius: 5px; +} + +.input-validation { + color: var(--error); +} + +.button-area { + display: flex; + justify-content: center; +} + + +.button-area button { + padding: 10px; + border: 1px solid var(--primary-color); + border-radius: 5px; + background: var(--primary-color); + cursor: pointer; + color: var(--white); + width: 100%; + font-size: large; +} \ No newline at end of file diff --git a/web/src/Session.jsx b/web/src/Session.jsx new file mode 100644 index 0000000..bb8f816 --- /dev/null +++ b/web/src/Session.jsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react'; +import { SessionContext } from './hooks/useSession'; +import { baseUrl } from './constants/api'; + +export default function Session({ children }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const getUser = async () => { + try { + const res = await fetch(`${baseUrl}/user`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }); + + if (res.status !== 200) { + throw new Error('Failed to get user'); + } + + const data = await res.json(); + setUser(data); + } catch (error) { + console.log(error); + } finally { + setLoading(false); + } + }; + + getUser(); + }, []); + + const handleSetUser = newUser => { + setUser(newUser); + }; + + if (loading) { + return
Loading...
; + } + + return {children}; +} diff --git a/web/src/assets/react.svg b/web/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/web/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/constants/api.js b/web/src/constants/api.js new file mode 100644 index 0000000..715e3de --- /dev/null +++ b/web/src/constants/api.js @@ -0,0 +1 @@ +export const baseUrl = import.meta.env.MODE === 'development' ? '/api' : ''; diff --git a/web/src/hooks/useSession.js b/web/src/hooks/useSession.js new file mode 100644 index 0000000..843bda0 --- /dev/null +++ b/web/src/hooks/useSession.js @@ -0,0 +1,13 @@ +import { createContext, useContext } from 'react'; + +export const SessionContext = createContext({ user: null, setUser: () => {} }); + +export const useSession = () => { + const context = useContext(SessionContext); + if (!context) { + throw new Error('useSession must be used within a Session'); + } + + const { user, setUser } = context; + return { user, setUser }; +}; diff --git a/web/src/main.jsx b/web/src/main.jsx new file mode 100644 index 0000000..05116c9 --- /dev/null +++ b/web/src/main.jsx @@ -0,0 +1,12 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App.jsx'; +import Session from './Session.jsx'; + +ReactDOM.createRoot(document.getElementById('root')).render( + + + + + +); diff --git a/web/vite.config.js b/web/vite.config.js new file mode 100644 index 0000000..1e15865 --- /dev/null +++ b/web/vite.config.js @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + build: { + outDir: 'build' + }, + resolve: { + alias: { + src: '/src' + } + }, + server: { + proxy: { + '/api': { + target: 'http://localhost:16321', + changeOrigin: true, + rewrite: path => path.replace(/^\/api/, '') + } + } + } +});