29 Commits
Author SHA1 Message Date
Emil Shanaty e23ea8d200 Update README.md 2026-02-04 00:04:04 +03:00
Emil Shanaty 64aa4208eb Update README.md 2026-02-04 00:03:18 +03:00
Emil Shanaty e7981c7100 Update README.md 2026-02-04 00:02:26 +03:00
Emil Shanaty 46d47307ad Update README.md 2026-02-03 23:46:08 +03:00
Emil Shanaty a984b1c7d3 Update README.md 2026-02-03 23:40:18 +03:00
Emil Shanaty dfa6cd92cf Update README.md 2026-02-03 23:37:14 +03:00
Emil Shanaty 1f2d53f286 Update README.md 2026-02-03 23:29:58 +03:00
emil28092005 8e86863f68 add login on front. 2026-01-30 12:33:51 +03:00
emil28092005 f2aa6e84ca add login on front. 2026-01-30 12:06:10 +03:00
emil28092005 99a1180bef add login on front. 2026-01-30 11:54:52 +03:00
emil28092005 58cadadf7f Add JWT authorization functionality. 2026-01-21 04:20:44 +03:00
emil28092005 b36b19af27 Harbor works! 2026-01-18 22:27:16 +03:00
emil28092005 909d3e9162 Harbor works! 2026-01-18 22:26:18 +03:00
emil28092005 07b178b488 Harbor works! 2026-01-18 21:36:22 +03:00
emil28092005 82cc3d396a Set up Harbor. 2026-01-18 21:30:52 +03:00
emil28092005 acea25f536 Set up Harbor. 2026-01-18 21:10:26 +03:00
emil28092005 2883cb6045 Minimal CI/CD works! 2026-01-18 06:23:02 +03:00
emil28092005 aeed25da38 Testing Github webhook cicd. 2026-01-18 06:21:12 +03:00
emil28092005 a26fc340eb Testing Github webhook cicd. 2026-01-18 06:17:39 +03:00
emil28092005 2e9ad750de Fix nginx. 2026-01-18 06:13:23 +03:00
emil28092005 f93d66c35b Testing Github webhook cicd. 2026-01-18 06:02:11 +03:00
emil28092005 1acd6b67c4 Testing Github webhook cicd. 2026-01-18 05:58:35 +03:00
emil28092005 89c12dace6 Testing Github webhook cicd. 2026-01-18 05:53:20 +03:00
emil28092005 d115624d85 Testing Github webhook cicd. 2026-01-18 05:52:24 +03:00
emil28092005 5e6f526446 Testing Github webhook cicd. 2026-01-18 05:47:15 +03:00
emil28092005 d7427c55dd Fix Jenkinsfile. 2026-01-18 05:46:28 +03:00
emil28092005 e05d1000f8 Testing Github webhook cicd. 2026-01-18 05:40:31 +03:00
Emil Shanaty 8437a82428 Merge pull request #6 from emil28092005/cicd-fix
Tryiing to fix Jenkins.
2026-01-18 05:23:55 +03:00
Emil Shanaty 0e70e7d2bc Merge pull request #5 from emil28092005/cicd-fix
Cicd fix
2026-01-18 05:18:52 +03:00
13 changed files with 511 additions and 140 deletions
Vendored
+58 -26
View File
@@ -6,12 +6,13 @@ pipeline {
timestamps()
}
environment {
IMAGE_NAME = "shagram"
DEPLOY_DIR = "/opt/shagram/shagram/deploy/shagram"
environment {
REGISTRY = "158.160.92.116:8085"
REGISTRY_PROJECT = "shagram"
IMAGE_NAME = "shagram"
DEPLOY_DIR = "/opt/shagram/shagram/deploy/shagram"
COMPOSE_FILE = "${WORKSPACE}/deploy/shagram/compose.yaml"
}
}
stages {
stage('Checkout') {
@@ -25,43 +26,74 @@ pipeline {
}
}
stage('Build') {
stage('Detect branch') {
steps {
script {
env.GIT_BRANCH = sh(
script: "git name-rev --name-only --refs=refs/remotes/origin/* HEAD | sed 's#^remotes/##' | head -n1",
returnStdout: true
).trim()
echo "Detected branch: ${env.GIT_BRANCH}"
}
}
}
stage('Build image') {
steps {
script {
env.GIT_SHA = sh(script: "git rev-parse --short HEAD", returnStdout: true).trim()
env.APP_IMAGE = "${IMAGE_NAME}:${env.GIT_SHA}"
// итоговый тег для Harbor
env.APP_IMAGE = "${REGISTRY}/${REGISTRY_PROJECT}/${IMAGE_NAME}:${env.GIT_SHA}"
}
sh '''
set -eux
docker build -t "$APP_IMAGE" .
# собираем локальный образ без registry
docker build -t "${IMAGE_NAME}:${GIT_SHA}" .
'''
}
}
stage('Login & Push to Harbor') {
steps {
withCredentials([usernamePassword(
credentialsId: 'harbor-creds',
usernameVariable: 'HARBOR_USER',
passwordVariable: 'HARBOR_PASS'
)]) {
sh '''
set -eux
echo "$HARBOR_PASS" | docker login "$REGISTRY" -u "$HARBOR_USER" --password-stdin
docker tag "${IMAGE_NAME}:${GIT_SHA}" "${APP_IMAGE}"
docker push "${APP_IMAGE}"
'''
}
}
}
stage('Test') {
steps {
sh '''
echo "Testing..."
'''
sh 'echo "Testing..."'
}
}
stage('Deploy') {
when {
beforeAgent true
branch 'main'
}
steps {
sh '''
set -eux
mkdir -p "$DEPLOY_DIR"
cat > "$DEPLOY_DIR/.env" <<EOF
APP_IMAGE=$APP_IMAGE
EOF
docker compose -f "$COMPOSE_FILE" --project-directory "$DEPLOY_DIR" up -d --remove-orphans
'''
}
}
when {
expression { env.GIT_BRANCH == 'origin/main' }
}
steps {
sh '''
set -eux
mkdir -p "$DEPLOY_DIR"
cat > "$DEPLOY_DIR/.env" <<EOF
APP_IMAGE=${APP_IMAGE}
EOF
docker compose -f "$COMPOSE_FILE" --project-directory "$DEPLOY_DIR" pull
docker compose -f "$COMPOSE_FILE" --project-directory "$DEPLOY_DIR" up -d --remove-orphans
docker compose -f "$COMPOSE_FILE" --project-directory "$DEPLOY_DIR" ps
'''
}
}
}
}
+141
View File
@@ -0,0 +1,141 @@
# Shagram — Go WebSocket Chat | DevOps Pet Project
Shagram is a small real-time chat application written in Go.
This is primarily a **DevOps-focused pet project**: the app itself is intentionally simple, while the main goal is to practice CI/CD, containerization, reverse proxy/TLS, and operating self-hosted infrastructure.
> Public repo note: I intentionally do not publish any real server URLs, IP addresses, credentials, or registry endpoints here.
## Documentation
Detailed setup guides are kept in separate README files to avoid duplication:
- Jenkins CI infrastructure (controller + inbound Docker agent): `infra/jenkins/README.md`
- Nginx TLS certificates for dev/demo (self-signed): `deploy/shagram/nginx/certs/README.md`
## What it does
- Multi-room chat via WebSockets (`/ws/:room`) with message broadcast.
- Message history persisted in SQLite and available through an HTTP API.
- Minimal browser UI served from `./static`.
## DevOps skills demonstrated
- Cloud: Deployed and operated on a VPS in **Yandex Cloud** (self-managed infrastructure).
- Docker: Multi-stage image build for a Go service; small runtime image.
- Docker Compose: Stack orchestration for the app (Go service + Nginx) and for CI infrastructure (Jenkins controller + agent).
- Nginx: Reverse proxy configuration for HTTP + WebSocket (Upgrade/Connection headers) and TLS termination.
- TLS: Self-signed certificates for dev/demo environments; production note to use a trusted CA (e.g., Lets Encrypt).
- CI/CD with Jenkins: Pipeline that builds a Docker image, tags it, pushes it to a registry, and deploys via Docker Compose (deploy gated to `main`).
- GitHub → Jenkins automation: GitHub repository webhook triggers Jenkins builds on push/changes.
- Private registry (Harbor): Self-hosted registry for storing and distributing built images.
- Jenkins agent architecture: Dedicated inbound Docker agent for builds, with Docker socket mounting for Docker-based workloads (security caveat applies).
- Ops automation: One-command restart/update script (`restart-stacks.sh`) that pulls and recreates Shagram, Jenkins, and Harbor stacks.
## Roadmap
- Kubernetes: Migrate deployment from Docker Compose to Kubernetes (manifests/Helm), add Ingress + cert-manager, and prepare the app for future scaling (multi-replica WebSocket strategy and persistent storage).
## Application stack
- Go + Gin (HTTP API and routing) (`cmd/server`).
- WebSockets: Gorilla WebSocket (`internal/api`, `internal/websocket`).
- Auth: JWT access tokens (`internal/auth`); login endpoint issues tokens; WebSocket uses `?token=...`.
- Storage: SQLite (`internal/db`) initialized from `migrations/schema.sql`.
## Configuration
Environment variables:
- `JWT_SECRET` (required): Signing key for JWT tokens.
- `DATABASE_PATH` (optional): SQLite file path; defaults to `/app/data/shagram.db` in the container.
- `WS_ALLOWED_ORIGINS` (required for WebSocket): Comma-separated list of allowed `Origin` values for browser WebSocket connections.
- `APP_IMAGE` (optional, deployment): Docker image reference used by Compose to deploy a prebuilt image.
## HTTP API
Server listens on `:8080`.
- `POST /api/auth/login``{ "access_token": "..." }`
- `GET /api/me` (requires `Authorization: Bearer <token>`) → `{ "username": "..." }`
- `GET /api/rooms``{ "rooms": [...] }`
- `GET /api/messages/:room``{ "messages": [...] }` (last 50 messages)
## WebSocket
Endpoint:
- `ws(s)://<host>/ws/<room>?token=<access_token>`
Client sends JSON:
```json
{ "text": "hello" }
```
Server broadcasts plain text messages:
- `alice: hello`
Security notes:
- WebSocket requires a JWT token (`?token=...`) and validates the `Origin` header against `WS_ALLOWED_ORIGINS`.
- This project is still a learning lab; for production you would additionally harden auth, rate limits, and request validation.
## Data model (SQLite)
Schema is created on startup from `migrations/schema.sql` and includes:
- `rooms(id, name)`
- `messages(id, room_id, user, text, created_at)`
## Quickstart (local, without Docker)
Prerequisites: Go toolchain.
```bash
export JWT_SECRET=change-me
export DATABASE_PATH=./shagram.db
export WS_ALLOWED_ORIGINS=http://localhost:8080
go run ./cmd/server
```
Open:
- http://localhost:8080
## Quickstart (Docker Compose + Nginx TLS)
Prerequisites: Docker + Docker Compose.
1) Generate a self-signed TLS certificate (dev/demo only):
See `deploy/shagram/nginx/certs/README.md`.
2) Create `deploy/shagram/.env`:
```text
JWT_SECRET=change-me
WS_ALLOWED_ORIGINS=https://localhost
Optional: override the app image (e.g., from your registry)
APP_IMAGE=<your-registry>/<project>/shagram:<tag>
```
3) Start:
```bash
cd deploy/shagram
docker compose up -d --build
```
Open:
- https://localhost
## CI/CD overview (Jenkins + Registry)
- Jenkins pipeline builds a Docker image from this repository, tags it, pushes it to a registry, and deploys the updated stack with Docker Compose (deploy gated to `main`).
- Jenkins is triggered by a GitHub repository webhook (push/changes).
- Jenkins setup instructions: `infra/jenkins/README.md`.
## Ops: restart / update all stacks
On the host, `restart-stacks.sh` can be used to pull and recreate:
- Shagram stack (app + nginx)
- Jenkins stack (controller + agent)
- Harbor stack (registry)
## Repository structure
- `cmd/server/` — Gin router, endpoints, wiring.
- `internal/auth/` — JWT issuing/parsing + Gin auth middleware.
- `internal/api/` — WebSocket handler (token validation + origin check + DB persistence + broadcast).
- `internal/websocket/` — In-memory hub/rooms and broadcast logic.
- `internal/db/` — SQLite connection + schema bootstrap.
- `migrations/` — SQL schema.
- `static/` — Minimal web UI.
- `deploy/shagram/` — Docker Compose + Nginx config/certs.
- `infra/jenkins/` — Jenkins controller/agent Compose setup and docs.
## Notes / trade-offs
- The WebSocket hub is in-memory, so the app is intended to run as a single instance (no horizontal scaling).
- Jenkins Docker agent mounts `/var/run/docker.sock`, which provides high-level control of the Docker host; use only in trusted environments.
- Self-signed TLS is for development/demo; production should use a trusted CA (e.g., Lets Encrypt).
+23
View File
@@ -5,9 +5,11 @@ import (
"net/http"
"os"
"shagram/internal/api"
"shagram/internal/auth"
"shagram/internal/db"
"shagram/internal/models"
"shagram/internal/websocket"
"time"
"github.com/gin-gonic/gin"
)
@@ -29,6 +31,27 @@ func main() {
router := gin.Default()
router.POST("/api/auth/login", func(c *gin.Context) {
var req struct {
Username string `json:"username"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "username required"})
return
}
token, err := auth.NewAccessToken(req.Username, time.Hour)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"access_token": token})
})
router.GET("/api/me", auth.Middleware(), func(c *gin.Context) {
username := c.GetString(auth.CtxUsernameKey)
c.JSON(200, gin.H{"username": username})
})
router.GET("/ws/:room", func(c *gin.Context) {
api.WebSocketHandler(hub, database)(c)
})
+5 -3
View File
@@ -1,16 +1,18 @@
services:
shagram:
image: ${APP_IMAGE:-shagram:local}
build:
build:
context: ../..
container_name: shagram-app
environment:
- WS_ALLOWED_ORIGINS=${WS_ALLOWED_ORIGINS}
- DATABASE_PATH=/app/data/shagram.db
- JWT_SECRET=${JWT_SECRET}
volumes:
- shagram_data:/app/data
expose:
- "8080"
nginx:
image: nginx:alpine
container_name: shagram-nginx
@@ -23,4 +25,4 @@ services:
depends_on:
- shagram
volumes:
shagram_data:
shagram_data:
+10 -5
View File
@@ -1,9 +1,9 @@
# TLS Certificates for Nginx (Development / Self-Signed)
In production, TLS certificates are typically issued by a trusted Certificate Authority (for example, via Lets Encrypt).
For local development and demo environments, a self-signed certificate can be used.
In production, TLS certificates are typically issued by a trusted Certificate Authority (for example, via Lets Encrypt). For local development and demo environments, a self-signed certificate can be used.
## Generate a self-signed certificate (Linux/macOS)
From the repository root:
```bash
@@ -17,16 +17,21 @@ openssl req -x509 -newkey rsa:4096 \
```
## Verify
```bash
openssl x509 -in deploy/shagram/nginx/certs/cert.pem -noout -text | head
```
## Usage
The Nginx configuration expects:
## Expected files
The Nginx configuration expects these files to exist:
- `deploy/shagram/nginx/certs/cert.pem`
- `deploy/shagram/nginx/certs/key.pem`
Start the deployment:
## Usage
After generating the certificate, start the deployment:
```bash
cd deploy/shagram
+1
View File
@@ -14,6 +14,7 @@ require (
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+2
View File
@@ -22,6 +22,8 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+44 -42
View File
@@ -1,13 +1,15 @@
# Jenkins (Controller + Docker Agent) Setup (Docker Compose)
# Jenkins Controller + Docker Agent (Docker Compose)
This directory contains a minimal Jenkins setup using a dedicated **controller** and a separate inbound **agent** intended for Docker-based workloads.
This directory contains a minimal Jenkins setup running as Docker containers:
- `jenkins-controller`: Jenkins UI + configuration (no builds should run here).
- `jenkins-agent-docker`: inbound agent used for Docker-based pipelines (label: `docker-agent`).
The goal is to keep the controller responsible for orchestration and configuration, while all builds run on the agent labeled `docker`.
The goal is to keep the controller responsible for orchestration/configuration while all CI jobs run on the dedicated agent.
## Prerequisites
- Docker Engine installed on the host
- Docker Compose available as `docker compose`
- A host directory `/opt/shagram` (used by pipelines as a shared location for source code and deployment files)
- Docker Engine installed on the host.
- Docker Compose available as `docker compose`.
- A host directory `/opt/shagram` (used by pipelines as a shared location for source code and deployment files).
## Start Jenkins
From the repository root:
@@ -20,59 +22,59 @@ docker compose ps
Jenkins UI will be available at:
- http://<server-ip>:8080
### Initial admin password
Retrieve the initial password with:
```bash
docker exec -it jenkins-controller cat /var/jenkins_home/secrets/initialAdminPassword
```text
http://<server-ip>:8080
```
## Disable builds on the built-in node (Executors = 0)
## Initial admin password
Retrieve the initial password:
```bash
docker exec -it jenkins-controller \
cat /var/jenkins_home/secrets/initialAdminPassword
```
## Disable builds on the controller
To ensure builds do not run on the controller:
1. Open Jenkins UI.
2. Go to: `Manage Jenkins``Manage Nodes and Clouds`.
3. Open: `Built-In Node``Configure`.
4. Set **Number of executors** to `0`.
5. Save.
1. Open Jenkins UI
2. Go to: **Manage Jenkins → Manage Nodes and Clouds**
3. Open **Built-In Node → Configure**
4. Set **Number of executors** to `0`
5. Save
## Create an inbound agent node (docker-agent)
## Configure the inbound agent node
Create a dedicated node for running pipelines:
1. Go to: **Manage Jenkins → Manage Nodes and Clouds → New Node**
1. `Manage Jenkins``Manage Nodes and Clouds``New Node`.
2. Set:
- **Node name**: `docker-agent`
- **Type**: Permanent Agent
- **Remote root directory**: `/home/jenkins/agent`
- **Labels**: `docker`
- **Usage**: Only build jobs with label expressions matching this node
3. Save
- Node name: `docker-agent`
- Type: `Permanent Agent`
- Remote root directory: `/home/jenkins/agent`
- Labels: `docker-agent` (must match your Jenkinsfile `agent { label ... }`)
- Usage: Only build jobs with label expressions matching this node
3. Save.
After saving, open the agent page:
- `Manage Nodes and Clouds``docker-agent`
- **Manage Nodes and Clouds → docker-agent**
On that page Jenkins will show the inbound connection details, including the **secret** required by the inbound agent container.
On that page, Jenkins provides the inbound connection details, including the **secret** required by the inbound agent container.
## Set agent secret in Compose
Edit `infra/jenkins/compose.yaml` and replace the placeholder with the real secret value shown on the `docker-agent` node page:
## Configure the agent secret in Compose
Edit `infra/jenkins/compose.yaml` and replace:
- `JENKINS_SECRET=PASTE_ME`
- `JENKINS_SECRET=__PASTE_ME__`
with the real secret value shown on the `docker-agent` node page.
Restart only the agent container:
Then restart only the agent container:
```bash
docker compose up -d --force-recreate jenkins-agent-docker
docker logs -f jenkins-agent-docker
```
## Verification
- In Jenkins UI: **Manage Nodes and Clouds**, the node `docker-agent` should be **Online**
- Any pipeline using `agent { label 'docker' }` should execute on this agent
Verification:
- In Jenkins UI, the node `docker-agent` should become **Online**.
- Any pipeline using `agent { label 'docker-agent' }` should execute on this agent.
## Security note
The Docker agent container mounts `/var/run/docker.sock`, which effectively grants high-level control over the Docker host. Use this setup only in trusted environments and limit access to Jenkins accordingly.
The Docker agent mounts `/var/run/docker.sock`, which effectively grants high-level control over the Docker host.
Use this setup only in trusted environments and limit access to Jenkins accordingly.
+38 -11
View File
@@ -3,15 +3,30 @@ package api
import (
"log"
"net/http"
"os"
"shagram/internal/auth"
"shagram/internal/db"
"shagram/internal/websocket"
"strings"
"github.com/gin-gonic/gin"
gws "github.com/gorilla/websocket"
)
var upgrader = gws.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return false
}
allowed := strings.Split(os.Getenv("WS_ALLOWED_ORIGINS"), ",")
for _, a := range allowed {
if strings.TrimSpace(a) == origin {
return true
}
}
return false
},
}
func WebSocketHandler(hub *websocket.Hub, database *db.DB) gin.HandlerFunc {
@@ -19,6 +34,19 @@ func WebSocketHandler(hub *websocket.Hub, database *db.DB) gin.HandlerFunc {
roomID := c.Param("room")
room := hub.GetOrCreateRoom(roomID)
tokenString := c.Query("token")
if tokenString == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
return
}
claims, err := auth.ParseAccessToken(tokenString)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
username := claims.Username
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
@@ -35,25 +63,24 @@ func WebSocketHandler(hub *websocket.Hub, database *db.DB) gin.HandlerFunc {
for {
var msg map[string]string
err := client.Conn.ReadJSON(&msg)
if err != nil {
if err := client.Conn.ReadJSON(&msg); err != nil {
break
}
user := msg["user"]
if user == "" {
user = "Anonymous"
text := msg["text"]
if text == "" {
continue
}
_, err = database.Exec(`
INSERT INTO messages (room_id, user, text)
VALUES (?, ?, ?)`, roomID, user, msg["text"])
VALUES (?, ?, ?)`, roomID, username, text)
if err != nil {
log.Printf("Save message error: %v", err)
}
formattedMsg := user + ": " + msg["text"]
message := []byte(formattedMsg)
room.Broadcast(message)
room.Broadcast([]byte(username + ": " + text))
}
}()
}
}
+64
View File
@@ -0,0 +1,64 @@
package auth
import (
"errors"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
Username string `json:"username"`
jwt.RegisteredClaims
}
func secret() ([]byte, error) {
s := os.Getenv("JWT_SECRET")
if s == "" {
return nil, errors.New("JWT_SECRET is not set")
}
return []byte(s), nil
}
func NewAccessToken(username string, ttl time.Duration) (string, error) {
key, err := secret()
if err != nil {
return "", err
}
now := time.Now()
claims := Claims{
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
Subject: username,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
},
}
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return t.SignedString(key)
}
func ParseAccessToken(tokenString string) (*Claims, error) {
key, err := secret()
if err != nil {
return nil, err
}
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
return key, nil
},
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
)
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
+41
View File
@@ -0,0 +1,41 @@
package auth
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
const CtxUsernameKey = "username"
func Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
h := c.GetHeader("Authorization")
if h == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing Authorization header"})
return
}
const prefix = "Bearer "
if !strings.HasPrefix(h, prefix) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "expected Bearer token"})
return
}
raw := strings.TrimSpace(strings.TrimPrefix(h, prefix))
if raw == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "empty token"})
return
}
claims, err := ParseAccessToken(raw)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
c.Set(CtxUsernameKey, claims.Username)
c.Next()
}
}
+17 -13
View File
@@ -4,18 +4,18 @@
<title>Shagram Chat</title>
<style>
body { font-family: Arial; margin: 20px; }
#messages {
height: 300px;
border: 1px solid #ccc;
overflow-y: auto;
padding: 10px;
#messages {
height: 300px;
border: 1px solid #ccc;
overflow-y: auto;
padding: 10px;
margin-bottom: 10px;
background: #f9f9f9;
}
.message {
margin: 5px 0;
padding: 8px;
background: #e3f2fd;
.message {
margin: 5px 0;
padding: 8px;
background: #e3f2fd;
border-radius: 5px;
border-left: 3px solid #2196F3;
}
@@ -23,25 +23,29 @@
button { padding: 8px 15px; cursor: pointer; }
select { padding: 8px; width: 320px; }
#roomsList { margin: 10px 0; }
#authStatus { margin-left: 10px; }
</style>
</head>
<body>
<h1>📱 Shagram Chat</h1>
<div id="roomsList">
<select id="roomSelect">
<option value="general">general</option>
</select>
<input type="text" id="userInput" placeholder="Your name" value="Эмиль" maxlength="20">
<button onclick="login()">Login</button>
<span id="authStatus"></span>
<button onclick="loadRooms()">🔄</button>
<button onclick="connectRoom()">Connect</button>
</div>
<div id="messages"></div>
<div>
<input type="text" id="messageInput" placeholder="Type message..."
<input type="text" id="messageInput" placeholder="Type message..."
onkeypress="if(event.key==='Enter') sendMessage()">
<button onclick="sendMessage()">Send</button>
</div>
+67 -40
View File
@@ -1,71 +1,103 @@
let ws = null;
let accessToken = null;
const messagesDiv = document.getElementById('messages');
const messageInput = document.getElementById('messageInput');
const roomSelect = document.getElementById('roomSelect');
const authStatus = document.getElementById('authStatus');
async function login() {
const usernameInput = document.getElementById('userInput');
const username = usernameInput ? usernameInput.value.trim() : '';
if (!username) {
alert('Enter username');
return;
}
authStatus.textContent = 'Logging in...';
const resp = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
});
if (!resp.ok) {
const text = await resp.text();
authStatus.textContent = 'Login failed';
console.error('Login failed:', resp.status, text);
return;
}
const data = await resp.json();
accessToken = data.access_token || null;
if (!accessToken) {
authStatus.textContent = 'Login failed';
return;
}
authStatus.textContent = '✅ Logged in';
}
function loadRooms() {
fetch('/api/rooms')
.then(response => response.json())
.then(data => {
roomSelect.innerHTML = '';
data.rooms.forEach(room => {
const option = document.createElement('option');
option.value = room;
option.textContent = room;
roomSelect.appendChild(option);
.then(response => response.json())
.then(data => {
roomSelect.innerHTML = '';
(data.rooms || []).forEach(room => {
const option = document.createElement('option');
option.value = room;
option.textContent = room;
roomSelect.appendChild(option);
});
})
.catch(error => {
console.error('Error loading rooms:', error);
});
})
.catch(error => {
console.error('Error loading rooms:', error);
})
}
function connectRoom() {
const room = roomSelect.value || 'general';
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
const host = window.location.host;
if (!accessToken) {
alert('Please login first');
return;
}
if (ws) {
ws.close();
}
const wsUrl = `${protocol}//${host}/ws/${room}`;
const wsUrl = `${protocol}//${host}/ws/${room}?token=${encodeURIComponent(accessToken)}`;
ws = new WebSocket(wsUrl);
ws.onopen = function() {
console.log('WebSocket connected, loading history for', room);
fetch(`/api/messages/${room}`)
.then(response => {
console.log('API response:', response.status);
return response.json()
})
.then(response => response.json())
.then(data => {
console.log('History data:', data);
messagesDiv.innerHTML = '';
const history = data.messages || [];
console.log('history length:', history.length);
if (history.length > 0) {
history.reverse().forEach((msg, index) => {
console.log(`Message ${index}:`, msg)
history.reverse().forEach(msg => {
const msgDiv = document.createElement('div');
msgDiv.className = 'message';
msgDiv.textContent = `${msg.user}: ${msg.text}`;
messagesDiv.appendChild(msgDiv);
})
});
}
const connectMsg = document.createElement('div');
connectMsg.className = 'message';
connectMsg.textContent = `✅ Connected to ${room}`;
messagesDiv.appendChild(connectMsg);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
console.log('History loaded!');
})
.catch(error => {
console.error('History error', error);
})
console.error('History error', error);
});
};
ws.onmessage = function(event) {
@@ -88,19 +120,14 @@ function connectRoom() {
function sendMessage() {
const text = messageInput.value.trim();
const usernameInput = document.getElementById('userInput');
const username = usernameInput ? usernameInput.value.trim() || 'Anonymous' : 'Anonymous';
if (!text || !ws || ws.readyState !== WebSocket.OPEN) {
alert('Not connected or empty message');
return;
}
ws.send(JSON.stringify({
text: text,
user: username,
}));
ws.send(JSON.stringify({ text }));
messageInput.value = '';
}
loadRooms();
loadRooms();