Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e23ea8d200 | ||
|
|
64aa4208eb | ||
|
|
e7981c7100 | ||
|
|
46d47307ad | ||
|
|
a984b1c7d3 | ||
|
|
dfa6cd92cf | ||
|
|
1f2d53f286 | ||
|
|
8e86863f68 | ||
|
|
f2aa6e84ca | ||
|
|
99a1180bef | ||
|
|
58cadadf7f | ||
|
|
b36b19af27 | ||
|
|
909d3e9162 | ||
|
|
07b178b488 | ||
|
|
82cc3d396a | ||
|
|
acea25f536 | ||
|
|
2883cb6045 | ||
|
|
aeed25da38 | ||
|
|
a26fc340eb | ||
|
|
2e9ad750de | ||
|
|
f93d66c35b | ||
|
|
1acd6b67c4 | ||
|
|
89c12dace6 | ||
|
|
d115624d85 | ||
|
|
5e6f526446 | ||
|
|
d7427c55dd | ||
|
|
e05d1000f8 | ||
|
|
8437a82428 | ||
|
|
bfe848f335 | ||
|
|
0e70e7d2bc | ||
|
|
cce78fa8b3 | ||
|
|
e67fb2ffe2 | ||
|
|
98f505ce88 | ||
|
|
da850e6086 | ||
|
|
4aa6aad6cb | ||
|
|
eb476c0b9d | ||
|
|
dab959d1a5 | ||
|
|
3929678a43 | ||
|
|
ce46878054 | ||
|
|
1e29b2e624 | ||
|
|
d2b12d2e5a | ||
|
|
c25952bbc1 | ||
|
|
5b8f9bc1ab | ||
|
|
04d0e2ae31 | ||
|
|
4259295ba3 | ||
|
|
b98326f510 | ||
|
|
7db2a78c0b | ||
|
|
cbc6ff04f6 | ||
|
|
c7955ecee7 | ||
|
|
acede2104a | ||
|
|
5895990cbb | ||
|
|
320d4d7502 | ||
|
|
cc70057b63 | ||
|
|
dbe3b79fb5 | ||
|
|
0e56d9f1f8 | ||
|
|
b894e3a6f1 | ||
|
|
19433276af | ||
|
|
552fc6b9a8 |
+3
-1
@@ -1,2 +1,4 @@
|
|||||||
project_context.txt
|
project_context.txt
|
||||||
shagram.db
|
shagram.db
|
||||||
|
deploy/shagram/nginx/certs/*.pem
|
||||||
|
deploy/shagram/.env
|
||||||
+10
-8
@@ -1,20 +1,22 @@
|
|||||||
FROM golang:1.25.5-alpine AS builder
|
FROM golang:1.25.5-alpine AS builder
|
||||||
|
RUN apk add --no-cache build-base ca-certificates
|
||||||
RUN apk add --no-cache build-base
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
|
go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o shagram ./cmd/server
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
|
--mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
CGO_ENABLED=1 GOOS=linux go build -o shagram ./cmd/server
|
||||||
|
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
RUN apk --no-cache add ca-certificates sqlite-libs
|
RUN apk add --no-cache ca-certificates sqlite-libs sqlite
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app/shagram .
|
COPY --from=builder /app/shagram .
|
||||||
COPY static ./static
|
COPY static ./static
|
||||||
COPY migrations ./migrations
|
COPY migrations ./migrations
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
CMD ["./shagram"]
|
CMD ["./shagram"]
|
||||||
|
|||||||
Vendored
+99
@@ -0,0 +1,99 @@
|
|||||||
|
pipeline {
|
||||||
|
agent { label 'docker-agent' }
|
||||||
|
|
||||||
|
options {
|
||||||
|
skipDefaultCheckout(true)
|
||||||
|
timestamps()
|
||||||
|
}
|
||||||
|
|
||||||
|
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') {
|
||||||
|
steps {
|
||||||
|
checkout scm
|
||||||
|
sh '''
|
||||||
|
set -eux
|
||||||
|
git reset --hard
|
||||||
|
git clean -xffd
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
// итоговый тег для Harbor
|
||||||
|
env.APP_IMAGE = "${REGISTRY}/${REGISTRY_PROJECT}/${IMAGE_NAME}:${env.GIT_SHA}"
|
||||||
|
}
|
||||||
|
sh '''
|
||||||
|
set -eux
|
||||||
|
# собираем локальный образ без 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..."'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Deploy') {
|
||||||
|
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
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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., Let’s 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., Let’s Encrypt).
|
||||||
+29
-1
@@ -3,16 +3,23 @@ package main
|
|||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"shagram/internal/api"
|
"shagram/internal/api"
|
||||||
|
"shagram/internal/auth"
|
||||||
"shagram/internal/db"
|
"shagram/internal/db"
|
||||||
"shagram/internal/models"
|
"shagram/internal/models"
|
||||||
"shagram/internal/websocket"
|
"shagram/internal/websocket"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
database, err := db.NewDB("/app/data/shagram.db")
|
dbPath := os.Getenv("DATABASE_PATH")
|
||||||
|
if dbPath == "" {
|
||||||
|
dbPath = "/app/data/shagram.db"
|
||||||
|
}
|
||||||
|
database, err := db.NewDB(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -24,6 +31,27 @@ func main() {
|
|||||||
|
|
||||||
router := gin.Default()
|
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) {
|
router.GET("/ws/:room", func(c *gin.Context) {
|
||||||
api.WebSocketHandler(hub, database)(c)
|
api.WebSocketHandler(hub, database)(c)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
services:
|
||||||
|
shagram:
|
||||||
|
image: ${APP_IMAGE:-shagram:local}
|
||||||
|
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
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||||
|
- ./nginx/certs:/etc/nginx/certs:ro
|
||||||
|
depends_on:
|
||||||
|
- shagram
|
||||||
|
volumes:
|
||||||
|
shagram_data:
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# TLS Certificates for Nginx (Development / Self-Signed)
|
||||||
|
|
||||||
|
In production, TLS certificates are typically issued by a trusted Certificate Authority (for example, via Let’s 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
|
||||||
|
mkdir -p deploy/shagram/nginx/certs
|
||||||
|
|
||||||
|
openssl req -x509 -newkey rsa:4096 \
|
||||||
|
-keyout deploy/shagram/nginx/certs/key.pem \
|
||||||
|
-out deploy/shagram/nginx/certs/cert.pem \
|
||||||
|
-sha256 -days 365 -nodes \
|
||||||
|
-subj "/C=RU/ST=Moscow/L=Korolyov/O=Shagram/CN=localhost"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl x509 -in deploy/shagram/nginx/certs/cert.pem -noout -text | head
|
||||||
|
```
|
||||||
|
|
||||||
|
## Expected files
|
||||||
|
|
||||||
|
The Nginx configuration expects these files to exist:
|
||||||
|
|
||||||
|
- `deploy/shagram/nginx/certs/cert.pem`
|
||||||
|
- `deploy/shagram/nginx/certs/key.pem`
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
After generating the certificate, start the deployment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd deploy/shagram
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
shagram:
|
|
||||||
build: .
|
|
||||||
ports:
|
|
||||||
- "8080"
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
- ./static:/app/static
|
|
||||||
- ./migrations:/app/migrations
|
|
||||||
environment:
|
|
||||||
- DATABASE_PATH=/app/shagram.db
|
|
||||||
container_name: shagram-app
|
|
||||||
|
|
||||||
nginx:
|
|
||||||
image: nginx:alpine
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
- "443:443"
|
|
||||||
volumes:
|
|
||||||
- ./nginx/conf.d/default.conf:/etc/nginx/conf.d/default.conf
|
|
||||||
- ./nginx/certs:/etc/nginx/certs
|
|
||||||
depends_on:
|
|
||||||
- shagram
|
|
||||||
container_name: shagram-nginx
|
|
||||||
@@ -14,6 +14,7 @@ require (
|
|||||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/goccy/go-yaml v1.18.0 // 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/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
|||||||
@@ -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-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 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
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/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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Jenkins Controller + Docker Agent (Docker Compose)
|
||||||
|
|
||||||
|
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/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).
|
||||||
|
|
||||||
|
## Start Jenkins
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd infra/jenkins
|
||||||
|
docker compose up -d
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Jenkins UI will be available at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://<server-ip>:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Configure the inbound agent node
|
||||||
|
Create a dedicated node for running pipelines:
|
||||||
|
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-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`
|
||||||
|
|
||||||
|
On that page Jenkins will show 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:
|
||||||
|
|
||||||
|
- `JENKINS_SECRET=PASTE_ME`
|
||||||
|
|
||||||
|
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, the node `docker-agent` should become **Online**.
|
||||||
|
- Any pipeline using `agent { label 'docker-agent' }` should execute on this agent.
|
||||||
|
|
||||||
|
## Security note
|
||||||
|
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.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
services:
|
||||||
|
jenkins-controller:
|
||||||
|
image: jenkins/jenkins:lts-jdk17
|
||||||
|
container_name: jenkins-controller
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- jenkins_home:/var/jenkins_home
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
jenkins-agent-docker:
|
||||||
|
image: jenkins/inbound-agent:latest-jdk17
|
||||||
|
container_name: jenkins-agent-docker
|
||||||
|
user: "1000:1000"
|
||||||
|
group_add:
|
||||||
|
- "988"
|
||||||
|
environment:
|
||||||
|
- JENKINS_URL=http://jenkins-controller:8080
|
||||||
|
- JENKINS_AGENT_NAME=docker-agent
|
||||||
|
- JENKINS_SECRET=0c92dc0b807b3c7213cd2abcec4f88182aa8f4fb2233ca9c674f17fd771fe662
|
||||||
|
- AGENT_WORKDIR=/home/jenkins/agent
|
||||||
|
volumes:
|
||||||
|
- agent_workdir:/home/jenkins/agent
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- /usr/bin/docker:/usr/bin/docker
|
||||||
|
- /usr/libexec/docker/cli-plugins:/usr/libexec/docker/cli-plugins:ro
|
||||||
|
- /opt/shagram:/opt/shagram
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- jenkins-controller
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
jenkins_home:
|
||||||
|
external: true
|
||||||
|
name: jenkins_jenkins_home
|
||||||
|
agent_workdir:
|
||||||
+38
-11
@@ -3,15 +3,30 @@ package api
|
|||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"shagram/internal/auth"
|
||||||
"shagram/internal/db"
|
"shagram/internal/db"
|
||||||
"shagram/internal/websocket"
|
"shagram/internal/websocket"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
gws "github.com/gorilla/websocket"
|
gws "github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
var upgrader = gws.Upgrader{
|
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 {
|
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")
|
roomID := c.Param("room")
|
||||||
room := hub.GetOrCreateRoom(roomID)
|
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)
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -35,25 +63,24 @@ func WebSocketHandler(hub *websocket.Hub, database *db.DB) gin.HandlerFunc {
|
|||||||
|
|
||||||
for {
|
for {
|
||||||
var msg map[string]string
|
var msg map[string]string
|
||||||
err := client.Conn.ReadJSON(&msg)
|
if err := client.Conn.ReadJSON(&msg); err != nil {
|
||||||
if err != nil {
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
user := msg["user"]
|
|
||||||
if user == "" {
|
text := msg["text"]
|
||||||
user = "Anonymous"
|
if text == "" {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = database.Exec(`
|
_, err = database.Exec(`
|
||||||
INSERT INTO messages (room_id, user, text)
|
INSERT INTO messages (room_id, user, text)
|
||||||
VALUES (?, ?, ?)`, roomID, user, msg["text"])
|
VALUES (?, ?, ?)`, roomID, username, text)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Save message error: %v", err)
|
log.Printf("Save message error: %v", err)
|
||||||
}
|
}
|
||||||
formattedMsg := user + ": " + msg["text"]
|
|
||||||
message := []byte(formattedMsg)
|
room.Broadcast([]byte(username + ": " + text))
|
||||||
room.Broadcast(message)
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
-----BEGIN CERTIFICATE-----
|
|
||||||
MIIDjzCCAnegAwIBAgIUG1Qc10mXTy5+Zuh1COAK6fqAl34wDQYJKoZIhvcNAQEL
|
|
||||||
BQAwVzELMAkGA1UEBhMCUlUxDzANBgNVBAgMBk1vc2NvdzERMA8GA1UEBwwIS29y
|
|
||||||
b2x5b3YxEDAOBgNVBAoMB1NoYWdyYW0xEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0y
|
|
||||||
NjAxMTQwMTMxNDhaFw0yNzAxMTQwMTMxNDhaMFcxCzAJBgNVBAYTAlJVMQ8wDQYD
|
|
||||||
VQQIDAZNb3Njb3cxETAPBgNVBAcMCEtvcm9seW92MRAwDgYDVQQKDAdTaGFncmFt
|
|
||||||
MRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
|
|
||||||
AoIBAQDLOB4WybryG6Cc4yU+2PPrLiM/tt15HMVp0TgcA277vofYFd90IArihbmG
|
|
||||||
KEaKYyZplUzxk55RRNS6BaAroZb0AWEg8cOid83gbnuKKaIUhjz/CDADPnnRWynb
|
|
||||||
PHjVuvIBtPLgyunbtB6U7a6JLP2nA8XaTcDFtLrhy8PTKFZ9fLRBXzxW8ZvNnL4i
|
|
||||||
FSLQYe851dOfJ1m0HwA/meK8QWfYn01O8zprleSMo+HPIudRR6ySnx/Cy3LuAcqM
|
|
||||||
sjttf6EFWs9XTiAOIbKC4M8VbOrfZjXFz45ljD0tZNziJ2g61lmdJAV8ElwZ6SDP
|
|
||||||
fGTwGjWj5Qx9Lg7qdFqjVyE0QFBJAgMBAAGjUzBRMB0GA1UdDgQWBBSd7S5Xngb4
|
|
||||||
9eeBbmTCslblkWClRDAfBgNVHSMEGDAWgBSd7S5Xngb49eeBbmTCslblkWClRDAP
|
|
||||||
BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQA8Lx0PAhfYRuRYuQxV
|
|
||||||
EyU4pmQ6JuAPl3gxVKHY6bFGMYooSRr5Nw/YulfPucxTKHtVlgY/l0kwofmTUh3B
|
|
||||||
Df1tulFCCmBW4zbl6cHd7TVSEJBkV7R9AqAC49ni8mVhmo3uPuARFa+mR8ATrF4y
|
|
||||||
LWlO/2n7F5g+093CqOG3b4O76PlqAl6L/z/Z+UBoMVEyiPgfxAztucsBn7oOaQwG
|
|
||||||
g6ohX2kosC2uRsc1bAofHWW0u3tFY0i6oiI7XPF9REuWJOiDlN2PsDMXhSG5xeLj
|
|
||||||
e7LUOQsNXB5deAfUFxbtEDCnEDzyp+GxXwZ0iPsJOzm2ccL6XdDzucCuR/NtuhGf
|
|
||||||
KwpD
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
-----BEGIN PRIVATE KEY-----
|
|
||||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDLOB4WybryG6Cc
|
|
||||||
4yU+2PPrLiM/tt15HMVp0TgcA277vofYFd90IArihbmGKEaKYyZplUzxk55RRNS6
|
|
||||||
BaAroZb0AWEg8cOid83gbnuKKaIUhjz/CDADPnnRWynbPHjVuvIBtPLgyunbtB6U
|
|
||||||
7a6JLP2nA8XaTcDFtLrhy8PTKFZ9fLRBXzxW8ZvNnL4iFSLQYe851dOfJ1m0HwA/
|
|
||||||
meK8QWfYn01O8zprleSMo+HPIudRR6ySnx/Cy3LuAcqMsjttf6EFWs9XTiAOIbKC
|
|
||||||
4M8VbOrfZjXFz45ljD0tZNziJ2g61lmdJAV8ElwZ6SDPfGTwGjWj5Qx9Lg7qdFqj
|
|
||||||
VyE0QFBJAgMBAAECggEALgf0UtOXd6M35omSBHIWkBknjVVRxc71TYKBS+EgOMA/
|
|
||||||
23ua3Z4rcQN60k9ZqRuL1iMmJls6Y2yspcVD8lYcEAGm+1Qf7PNnrBRCgflrt+vv
|
|
||||||
MZJsc7OpWrlkWf9Q3JLHogjXcgEsZyJdwfyzielpvDS/0nLFvVKyeRZTcUdEDhCz
|
|
||||||
MoY3tqcZVwq1k+CsW5Z8C1DZaEwgwhEXNGPgfX7R86FSNwlNmcTKJG/YHb9xBA84
|
|
||||||
O109pfe9PsQnYB9Lw+CNPHl1v6wIMRlVZZMFPevBJq+r9d8iN6dsX7c4oR8SXDDS
|
|
||||||
4Shux9RNE8XyD6HOTK+QuEvJmZmPIFjnlVZIsVkpHQKBgQD5bfeh7En+VPdHFGTg
|
|
||||||
+0o1ZSYrfvErak8zP7DoLZF4EY6CK20uZ8ou09ebWXZQfC37PliOSMJyDG70PuXc
|
|
||||||
Rh/TfnUPkPbmyRrvQi+MAsIjCEYGk2iiMQvSVs099AB6oAQetyPm2AcT4HeGNaLM
|
|
||||||
m1gurXTCelV0yT7oL7IhI6AqRQKBgQDQkoenTQ1nnvA4ghN253c+mbJWvTX+lbo9
|
|
||||||
WicOAxmA9gp/VN7NErXbgr5FcMwPtT+yLDqDgSjLp2T9ClYt/laWcO/KYwrnP9o5
|
|
||||||
DTeSB+biEAd3GHJLWJApY/dmFZey5uza+hXvlTdVCUgvFJYGgHw/Dg5E+jvUwrNT
|
|
||||||
MNN/OnpQNQKBgAtxxz3vuIlp3pqtTd+gyAvhIzo1rd0fGJkyX+yXQqhurco9MdpC
|
|
||||||
Ot2hLLBdD3er6vQvLSMCJaHT/jdIt4U+1nD+yWI4dYurSIgX0lSrP7sZwxTEKLXg
|
|
||||||
aDlzcCFak7cMpoO+RXBvEwwPbYyD439d1VL29HeD423jWfaPUa4Bk3S9AoGAWGdj
|
|
||||||
PBQ6tEr3wtvPaDyfnFcE8iLsueW4tLx7hULnEnQ26tWMQhvGHS6De2dd9uJ6Bwkc
|
|
||||||
HBUot3lSIra45HHDPazM1lm4i1/THQ9vGGRlBjiJEX+5Ihp9sC2A9TH9xISArCgI
|
|
||||||
GC6E73QptlrhZAwdnZRVlAMETR/hZkdxvaGJqmkCgYEAzHpqhLMFwOOvDr5Ya2DW
|
|
||||||
5IGSCohsNBRvPKLSXlMIT1yMfRStsp5qQrZq2Oj2xXX7Jk3jf+1lU3ZQ5O6bI6br
|
|
||||||
9MhrVKu6qI8YS3OtSDPAUOzPw1zu4yaKyCO0eNghY51PTZd3A3JA8Uh9Si7OCryD
|
|
||||||
iHnkhUQ1iasZLdnubyVUuh0=
|
|
||||||
-----END PRIVATE KEY-----
|
|
||||||
+17
-13
@@ -4,18 +4,18 @@
|
|||||||
<title>Shagram Chat</title>
|
<title>Shagram Chat</title>
|
||||||
<style>
|
<style>
|
||||||
body { font-family: Arial; margin: 20px; }
|
body { font-family: Arial; margin: 20px; }
|
||||||
#messages {
|
#messages {
|
||||||
height: 300px;
|
height: 300px;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
background: #f9f9f9;
|
background: #f9f9f9;
|
||||||
}
|
}
|
||||||
.message {
|
.message {
|
||||||
margin: 5px 0;
|
margin: 5px 0;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
background: #e3f2fd;
|
background: #e3f2fd;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
border-left: 3px solid #2196F3;
|
border-left: 3px solid #2196F3;
|
||||||
}
|
}
|
||||||
@@ -23,25 +23,29 @@
|
|||||||
button { padding: 8px 15px; cursor: pointer; }
|
button { padding: 8px 15px; cursor: pointer; }
|
||||||
select { padding: 8px; width: 320px; }
|
select { padding: 8px; width: 320px; }
|
||||||
#roomsList { margin: 10px 0; }
|
#roomsList { margin: 10px 0; }
|
||||||
|
#authStatus { margin-left: 10px; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>📱 Shagram Chat</h1>
|
<h1>📱 Shagram Chat</h1>
|
||||||
|
|
||||||
<div id="roomsList">
|
<div id="roomsList">
|
||||||
<select id="roomSelect">
|
<select id="roomSelect">
|
||||||
<option value="general">general</option>
|
<option value="general">general</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<input type="text" id="userInput" placeholder="Your name" value="Эмиль" maxlength="20">
|
<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="loadRooms()">🔄</button>
|
||||||
<button onclick="connectRoom()">Connect</button>
|
<button onclick="connectRoom()">Connect</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div id="messages"></div>
|
<div id="messages"></div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<input type="text" id="messageInput" placeholder="Type message..."
|
<input type="text" id="messageInput" placeholder="Type message..."
|
||||||
onkeypress="if(event.key==='Enter') sendMessage()">
|
onkeypress="if(event.key==='Enter') sendMessage()">
|
||||||
<button onclick="sendMessage()">Send</button>
|
<button onclick="sendMessage()">Send</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+67
-40
@@ -1,71 +1,103 @@
|
|||||||
let ws = null;
|
let ws = null;
|
||||||
|
let accessToken = null;
|
||||||
|
|
||||||
const messagesDiv = document.getElementById('messages');
|
const messagesDiv = document.getElementById('messages');
|
||||||
const messageInput = document.getElementById('messageInput');
|
const messageInput = document.getElementById('messageInput');
|
||||||
const roomSelect = document.getElementById('roomSelect');
|
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() {
|
function loadRooms() {
|
||||||
fetch('/api/rooms')
|
fetch('/api/rooms')
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
roomSelect.innerHTML = '';
|
roomSelect.innerHTML = '';
|
||||||
data.rooms.forEach(room => {
|
(data.rooms || []).forEach(room => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = room;
|
option.value = room;
|
||||||
option.textContent = room;
|
option.textContent = room;
|
||||||
roomSelect.appendChild(option);
|
roomSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading rooms:', error);
|
||||||
});
|
});
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error loading rooms:', error);
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectRoom() {
|
function connectRoom() {
|
||||||
const room = roomSelect.value || 'general';
|
const room = roomSelect.value || 'general';
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const host = window.location.host;
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
alert('Please login first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (ws) {
|
if (ws) {
|
||||||
ws.close();
|
ws.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
ws = new WebSocket(`${protocol}//localhost:8080/ws/${room}`);
|
const wsUrl = `${protocol}//${host}/ws/${room}?token=${encodeURIComponent(accessToken)}`;
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
|
||||||
ws.onopen = function() {
|
ws.onopen = function() {
|
||||||
console.log('WebSocket connected, loading history for', room);
|
|
||||||
|
|
||||||
fetch(`/api/messages/${room}`)
|
fetch(`/api/messages/${room}`)
|
||||||
.then(response => {
|
.then(response => response.json())
|
||||||
console.log('API response:', response.status);
|
|
||||||
return response.json()
|
|
||||||
})
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
console.log('History data:', data);
|
|
||||||
messagesDiv.innerHTML = '';
|
messagesDiv.innerHTML = '';
|
||||||
const history = data.messages || [];
|
const history = data.messages || [];
|
||||||
console.log('history length:', history.length);
|
|
||||||
if (history.length > 0) {
|
if (history.length > 0) {
|
||||||
history.reverse().forEach((msg, index) => {
|
history.reverse().forEach(msg => {
|
||||||
console.log(`Message ${index}:`, msg)
|
|
||||||
const msgDiv = document.createElement('div');
|
const msgDiv = document.createElement('div');
|
||||||
msgDiv.className = 'message';
|
msgDiv.className = 'message';
|
||||||
msgDiv.textContent = `${msg.user}: ${msg.text}`;
|
msgDiv.textContent = `${msg.user}: ${msg.text}`;
|
||||||
messagesDiv.appendChild(msgDiv);
|
messagesDiv.appendChild(msgDiv);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
const connectMsg = document.createElement('div');
|
const connectMsg = document.createElement('div');
|
||||||
connectMsg.className = 'message';
|
connectMsg.className = 'message';
|
||||||
connectMsg.textContent = `✅ Connected to ${room}`;
|
connectMsg.textContent = `✅ Connected to ${room}`;
|
||||||
messagesDiv.appendChild(connectMsg);
|
messagesDiv.appendChild(connectMsg);
|
||||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||||
console.log('History loaded!');
|
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('❌ History error', error);
|
console.error('History error', error);
|
||||||
})
|
});
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = function(event) {
|
ws.onmessage = function(event) {
|
||||||
@@ -88,19 +120,14 @@ function connectRoom() {
|
|||||||
|
|
||||||
function sendMessage() {
|
function sendMessage() {
|
||||||
const text = messageInput.value.trim();
|
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) {
|
if (!text || !ws || ws.readyState !== WebSocket.OPEN) {
|
||||||
alert('Not connected or empty message');
|
alert('Not connected or empty message');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({ text }));
|
||||||
text: text,
|
|
||||||
user: username,
|
|
||||||
}));
|
|
||||||
messageInput.value = '';
|
messageInput.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
loadRooms();
|
loadRooms();
|
||||||
|
|||||||
Reference in New Issue
Block a user