mirror of
https://github.com/Alexander-D-Karpov/webring.git
synced 2026-03-16 22:07:41 +03:00
Introduce full user system and approval workflow
——————————————————————————————————————————
Login & sessions
Telegram one‑click login (/login → /auth/telegram) with HMAC verification
New users and sessions tables; telegram_id now optional, TTL‑based cleanup job
Secure session_id cookie (configurable TTL and Secure/SameSite flags)
User dashboard (/user)
Lists the member’s sites and their uptime status
Forms to submit new site or update requests; validation and slug/url sanitisation
View pending requests with change diff
Request storage
update_requests table captures create/update ops as JSONB “changed_fields”
Admin review
/admin/requests interface to approve / reject queued requests
Approval auto‑creates sites (with ordered display_order) or patches existing ones, then refreshes favicon
Super‑admin panel
/admin/setup lists all users, toggle is_admin and forcibly logs them out
Notifications
On every new request, all admins with a Telegram ID receive a Markdown summary via bot API
Public UI tweaks
Header shows login/logout, role‑aware links and call‑to‑action cards
/submit page creates a queued request
Config & env
Added TELEGRAM_BOT_TOKEN, TELEGRAM_BOT_USERNAME, SESSION_TTL_HOURS, SESSION_SECURE_COOKIE
.env.template updated accordingly
Migrations 004–010
Users, sessions, foreign key on sites, display_order, update_requests, telegram_id nullability
BREAKING CHANGE
Environment must supply Telegram bot credentials
Database must be migrated; existing “dashboard” auth remains but admin routes are now session‑protected where applicable
343 lines
8.9 KiB
Go
343 lines
8.9 KiB
Go
package api
|
|
|
|
import (
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
|
|
"github.com/gorilla/mux"
|
|
)
|
|
|
|
const dirPerm = 0o755
|
|
|
|
// @title Webring API
|
|
// @version 1.0
|
|
// @description API for the webring
|
|
// @host localhost:8000
|
|
// @contact.url mailto://sanspie@akarpov.ru
|
|
// @BasePath /
|
|
|
|
func RegisterSwaggerHandlers(r *mux.Router) {
|
|
ensureDocsDirectory()
|
|
|
|
// Register specific JSON endpoint BEFORE the PathPrefix handler
|
|
r.HandleFunc("/docs/swagger.json", swaggerJSONHandler).Methods("GET")
|
|
r.PathPrefix("/docs/").Handler(http.StripPrefix("/docs/", http.FileServer(http.Dir("./docs/"))))
|
|
}
|
|
|
|
func ensureDocsDirectory() {
|
|
docsDir := "docs"
|
|
if err := os.MkdirAll(docsDir, dirPerm); err != nil {
|
|
log.Printf("Warning: Could not create docs directory: %v", err)
|
|
}
|
|
}
|
|
|
|
func swaggerJSONHandler(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if _, err := w.Write([]byte(swaggerJSON)); err != nil {
|
|
log.Printf("Error writing swagger JSON: %v", err)
|
|
}
|
|
}
|
|
|
|
const swaggerJSON = `{
|
|
"swagger": "2.0",
|
|
"info": {
|
|
"title": "Webring API",
|
|
"description": "Public API for navigating the webring",
|
|
"version": "1.0"
|
|
},
|
|
"host": "webring.otomir23.me",
|
|
"basePath": "/",
|
|
"schemes": ["https"],
|
|
"consumes": ["application/json"],
|
|
"produces": ["application/json"],
|
|
"paths": {
|
|
"/sites": {
|
|
"get": {
|
|
"summary": "List all active sites",
|
|
"description": "Returns a list of all sites that are currently responding (is_up = true)",
|
|
"tags": ["Sites"],
|
|
"responses": {
|
|
"200": {
|
|
"description": "List of active sites",
|
|
"schema": {
|
|
"type": "array",
|
|
"items": {
|
|
"$ref": "#/definitions/PublicSite"
|
|
}
|
|
}
|
|
},
|
|
"500": {
|
|
"description": "Internal server error"
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"/{slug}": {
|
|
"get": {
|
|
"summary": "Redirect to site",
|
|
"description": "Redirects to the URL of the specified site",
|
|
"tags": ["Navigation"],
|
|
"parameters": [
|
|
{
|
|
"name": "slug",
|
|
"in": "path",
|
|
"required": true,
|
|
"type": "string",
|
|
"description": "The unique slug identifier for the site"
|
|
}
|
|
],
|
|
"responses": {
|
|
"302": {
|
|
"description": "Redirect to site URL"
|
|
},
|
|
"404": {
|
|
"description": "Site not found"
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"/{slug}/data": {
|
|
"get": {
|
|
"summary": "Get site data with navigation",
|
|
"description": "Returns the current site along with previous and next sites in the ring",
|
|
"tags": ["Navigation"],
|
|
"parameters": [
|
|
{
|
|
"name": "slug",
|
|
"in": "path",
|
|
"required": true,
|
|
"type": "string",
|
|
"description": "The unique slug identifier for the site"
|
|
}
|
|
],
|
|
"responses": {
|
|
"200": {
|
|
"description": "Site data with navigation",
|
|
"schema": {
|
|
"$ref": "#/definitions/SiteData"
|
|
}
|
|
},
|
|
"404": {
|
|
"description": "Site not found"
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"/{slug}/next": {
|
|
"get": {
|
|
"summary": "Redirect to next site",
|
|
"description": "Redirects to the next site in the webring based on display order",
|
|
"tags": ["Navigation"],
|
|
"parameters": [
|
|
{
|
|
"name": "slug",
|
|
"in": "path",
|
|
"required": true,
|
|
"type": "string",
|
|
"description": "The unique slug identifier for the current site"
|
|
}
|
|
],
|
|
"responses": {
|
|
"302": {
|
|
"description": "Redirect to next site URL"
|
|
},
|
|
"404": {
|
|
"description": "Site not found"
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"/{slug}/next/data": {
|
|
"get": {
|
|
"summary": "Get next site data",
|
|
"description": "Returns data for the next site in the webring based on display order",
|
|
"tags": ["Navigation"],
|
|
"parameters": [
|
|
{
|
|
"name": "slug",
|
|
"in": "path",
|
|
"required": true,
|
|
"type": "string",
|
|
"description": "The unique slug identifier for the current site"
|
|
}
|
|
],
|
|
"responses": {
|
|
"200": {
|
|
"description": "Next site data",
|
|
"schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"next": {
|
|
"$ref": "#/definitions/PublicSite"
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"404": {
|
|
"description": "Site not found"
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"/{slug}/prev": {
|
|
"get": {
|
|
"summary": "Redirect to previous site",
|
|
"description": "Redirects to the previous site in the webring based on display order",
|
|
"tags": ["Navigation"],
|
|
"parameters": [
|
|
{
|
|
"name": "slug",
|
|
"in": "path",
|
|
"required": true,
|
|
"type": "string",
|
|
"description": "The unique slug identifier for the current site"
|
|
}
|
|
],
|
|
"responses": {
|
|
"302": {
|
|
"description": "Redirect to previous site URL"
|
|
},
|
|
"404": {
|
|
"description": "Site not found"
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"/{slug}/prev/data": {
|
|
"get": {
|
|
"summary": "Get previous site data",
|
|
"description": "Returns data for the previous site in the webring based on display order",
|
|
"tags": ["Navigation"],
|
|
"parameters": [
|
|
{
|
|
"name": "slug",
|
|
"in": "path",
|
|
"required": true,
|
|
"type": "string",
|
|
"description": "The unique slug identifier for the current site"
|
|
}
|
|
],
|
|
"responses": {
|
|
"200": {
|
|
"description": "Previous site data",
|
|
"schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"previous": {
|
|
"$ref": "#/definitions/PublicSite"
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"404": {
|
|
"description": "Site not found"
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"/{slug}/random": {
|
|
"get": {
|
|
"summary": "Redirect to random site",
|
|
"description": "Redirects to a random site in the webring (excluding the current site)",
|
|
"tags": ["Navigation"],
|
|
"parameters": [
|
|
{
|
|
"name": "slug",
|
|
"in": "path",
|
|
"required": true,
|
|
"type": "string",
|
|
"description": "The unique slug identifier for the current site"
|
|
}
|
|
],
|
|
"responses": {
|
|
"302": {
|
|
"description": "Redirect to random site URL"
|
|
},
|
|
"404": {
|
|
"description": "No available sites found"
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"/{slug}/random/data": {
|
|
"get": {
|
|
"summary": "Get random site data",
|
|
"description": "Returns data for a random site in the webring (excluding the current site)",
|
|
"tags": ["Navigation"],
|
|
"parameters": [
|
|
{
|
|
"name": "slug",
|
|
"in": "path",
|
|
"required": true,
|
|
"type": "string",
|
|
"description": "The unique slug identifier for the current site"
|
|
}
|
|
],
|
|
"responses": {
|
|
"200": {
|
|
"description": "Random site data",
|
|
"schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"random": {
|
|
"$ref": "#/definitions/PublicSite"
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"404": {
|
|
"description": "No available sites found"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"definitions": {
|
|
"PublicSite": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {
|
|
"type": "integer",
|
|
"description": "Unique identifier for the site"
|
|
},
|
|
"slug": {
|
|
"type": "string",
|
|
"description": "URL-friendly unique identifier"
|
|
},
|
|
"name": {
|
|
"type": "string",
|
|
"description": "Display name of the site"
|
|
},
|
|
"url": {
|
|
"type": "string",
|
|
"description": "Full URL of the site"
|
|
},
|
|
"favicon": {
|
|
"type": "string",
|
|
"description": "Path to the site's favicon (nullable)"
|
|
}
|
|
},
|
|
"required": ["id", "slug", "name", "url"]
|
|
},
|
|
"SiteData": {
|
|
"type": "object",
|
|
"properties": {
|
|
"prev": {
|
|
"$ref": "#/definitions/PublicSite",
|
|
"description": "Previous site in the webring"
|
|
},
|
|
"curr": {
|
|
"$ref": "#/definitions/PublicSite",
|
|
"description": "Current site"
|
|
},
|
|
"next": {
|
|
"$ref": "#/definitions/PublicSite",
|
|
"description": "Next site in the webring"
|
|
}
|
|
},
|
|
"required": ["prev", "curr", "next"]
|
|
}
|
|
}
|
|
}`
|