mirror of
https://github.com/Walter-Sparrow/lunar-tear.git
synced 2026-07-02 05:43:41 +03:00
Add authentication server, dev CLI, Docker multi-service setup, and cross-platform improvements
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed login.html
|
||||
var loginFS embed.FS
|
||||
|
||||
var loginTmpl = template.Must(template.ParseFS(loginFS, "login.html"))
|
||||
|
||||
type Handlers struct {
|
||||
store *AuthStore
|
||||
tok *TokenService
|
||||
}
|
||||
|
||||
func NewHandlers(store *AuthStore, tok *TokenService) *Handlers {
|
||||
return &Handlers{store: store, tok: tok}
|
||||
}
|
||||
|
||||
type loginPageData struct {
|
||||
RedirectURI string
|
||||
State string
|
||||
Error string
|
||||
Username string
|
||||
}
|
||||
|
||||
func isOAuthPath(path string) bool {
|
||||
// Match /v{N}/dialog/oauth or /v{N}.{M}/dialog/oauth
|
||||
parts := strings.Split(strings.TrimPrefix(path, "/"), "/")
|
||||
if len(parts) != 3 {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(parts[0], "v") && parts[1] == "dialog" && parts[2] == "oauth"
|
||||
}
|
||||
|
||||
func isMePath(path string) bool {
|
||||
p := strings.TrimPrefix(path, "/")
|
||||
if p == "me" {
|
||||
return true
|
||||
}
|
||||
parts := strings.Split(p, "/")
|
||||
return len(parts) == 2 && strings.HasPrefix(parts[0], "v") && parts[1] == "me"
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleOAuth(w http.ResponseWriter, r *http.Request) {
|
||||
if isMePath(r.URL.Path) {
|
||||
h.HandleMe(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if !isOAuthPath(r.URL.Path) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.oauthGet(w, r)
|
||||
case http.MethodPost:
|
||||
h.oauthPost(w, r)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) oauthGet(w http.ResponseWriter, r *http.Request) {
|
||||
data := loginPageData{
|
||||
RedirectURI: r.URL.Query().Get("redirect_uri"),
|
||||
State: r.URL.Query().Get("state"),
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := loginTmpl.Execute(w, data); err != nil {
|
||||
log.Printf("render login page: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) oauthPost(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
password := r.FormValue("password")
|
||||
action := r.FormValue("action")
|
||||
redirectURI := r.FormValue("redirect_uri")
|
||||
state := r.FormValue("state")
|
||||
|
||||
renderErr := func(msg string) {
|
||||
data := loginPageData{
|
||||
RedirectURI: redirectURI,
|
||||
State: state,
|
||||
Error: msg,
|
||||
Username: username,
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := loginTmpl.Execute(w, data); err != nil {
|
||||
log.Printf("render login page: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if username == "" || password == "" {
|
||||
renderErr("Username and password are required.")
|
||||
return
|
||||
}
|
||||
|
||||
var user AuthUser
|
||||
var err error
|
||||
|
||||
switch action {
|
||||
case "register":
|
||||
user, err = h.store.CreateUser(username, password)
|
||||
if err == ErrUserExists {
|
||||
renderErr("Username is already taken.")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("create user: %v", err)
|
||||
renderErr("Server error. Try again.")
|
||||
return
|
||||
}
|
||||
log.Printf("registered user %q (id=%d)", user.Username, user.ID)
|
||||
|
||||
case "login":
|
||||
user, err = h.store.VerifyUser(username, password)
|
||||
if err == ErrInvalidCreds {
|
||||
renderErr("Invalid username or password.")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("verify user: %v", err)
|
||||
renderErr("Server error. Try again.")
|
||||
return
|
||||
}
|
||||
log.Printf("authenticated user %q (id=%d)", user.Username, user.ID)
|
||||
|
||||
default:
|
||||
renderErr("Invalid action.")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := h.tok.Generate(user)
|
||||
if err != nil {
|
||||
log.Printf("generate token: %v", err)
|
||||
renderErr("Server error. Try again.")
|
||||
return
|
||||
}
|
||||
|
||||
payload := fmt.Sprintf(`{"user_id":"%d"}`, user.ID)
|
||||
b64 := base64.StdEncoding.EncodeToString([]byte(payload))
|
||||
|
||||
fragment := url.Values{}
|
||||
fragment.Set("access_token", token)
|
||||
fragment.Set("token_type", "bearer")
|
||||
fragment.Set("expires_in", strconv.FormatInt(int64(tokenTTL.Seconds()), 10))
|
||||
fragment.Set("signed_request", "0."+b64)
|
||||
if state != "" {
|
||||
fragment.Set("state", state)
|
||||
}
|
||||
|
||||
target := redirectURI + "?" + fragment.Encode()
|
||||
log.Printf("redirecting to %s", target)
|
||||
http.Redirect(w, r, target, http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleCheckUsername(w http.ResponseWriter, r *http.Request) {
|
||||
username := strings.TrimSpace(r.URL.Query().Get("username"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if username == "" {
|
||||
json.NewEncoder(w).Encode(map[string]bool{"exists": false})
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]bool{"exists": h.store.UserExists(username)})
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleMe(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.URL.Query().Get("access_token")
|
||||
if token == "" {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer ") {
|
||||
token = auth[7:]
|
||||
}
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
http.Error(w, `{"error":{"message":"missing access_token","type":"OAuthException","code":190}}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := h.tok.Validate(token)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf(`{"error":{"message":"%s","type":"OAuthException","code":190}}`, err), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"id": strconv.FormatInt(claims.Sub, 10),
|
||||
"name": claims.Name,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Lunar Tear – Login</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
background: #0a0a0a;
|
||||
color: #e0e0e0;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.card {
|
||||
background: #161616;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 8px;
|
||||
padding: 40px 32px 32px;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
font-size: 28px;
|
||||
font-weight: 300;
|
||||
letter-spacing: 6px;
|
||||
text-transform: uppercase;
|
||||
color: #c8c8c8;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
letter-spacing: 3px;
|
||||
text-transform: uppercase;
|
||||
color: #555;
|
||||
margin-bottom: 32px;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
.error {
|
||||
background: #2a1515;
|
||||
border: 1px solid #5a2020;
|
||||
color: #e08080;
|
||||
border-radius: 4px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: #888;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: #0e0e0e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
color: #e0e0e0;
|
||||
font-size: 15px;
|
||||
margin-bottom: 18px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
input:focus { border-color: #666; }
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
button {
|
||||
flex: 1;
|
||||
padding: 11px 0;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s, opacity 0.3s;
|
||||
}
|
||||
.btn-login {
|
||||
background: #e0e0e0;
|
||||
color: #111;
|
||||
border-color: #e0e0e0;
|
||||
}
|
||||
.btn-login:hover { background: #fff; border-color: #fff; }
|
||||
.btn-register {
|
||||
background: transparent;
|
||||
color: #aaa;
|
||||
}
|
||||
.btn-register:hover { border-color: #666; color: #e0e0e0; }
|
||||
.hidden { display: none; }
|
||||
@media (max-height: 480px) {
|
||||
body { align-items: stretch; padding: 0; }
|
||||
.card {
|
||||
max-width: none; border-radius: 0; border: none;
|
||||
min-height: 100vh; min-height: 100dvh;
|
||||
padding: 20px 24px;
|
||||
display: flex; flex-direction: column; justify-content: center;
|
||||
}
|
||||
h1 { font-size: 22px; margin-bottom: 4px; }
|
||||
.subtitle { margin-bottom: 16px; }
|
||||
input[type="text"],
|
||||
input[type="password"] { padding: 8px 10px; margin-bottom: 12px; }
|
||||
.buttons { margin-top: 4px; }
|
||||
button { padding: 9px 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<form class="card" method="POST">
|
||||
<h1>Lunar Tear</h1>
|
||||
<div class="subtitle" id="subtitle">Authentication</div>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<input type="hidden" name="redirect_uri" value="{{.RedirectURI}}">
|
||||
<input type="hidden" name="state" value="{{.State}}">
|
||||
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" value="{{.Username}}" autocomplete="username" autofocus required>
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit" name="action" value="login" class="btn-login hidden" id="btn-login">Login</button>
|
||||
<button type="submit" name="action" value="register" class="btn-register hidden" id="btn-register">Create Account</button>
|
||||
</div>
|
||||
</form>
|
||||
<script>
|
||||
(function() {
|
||||
var input = document.getElementById('username');
|
||||
var btnLogin = document.getElementById('btn-login');
|
||||
var btnRegister = document.getElementById('btn-register');
|
||||
var subtitle = document.getElementById('subtitle');
|
||||
var timer = null;
|
||||
var lastChecked = '';
|
||||
|
||||
function check() {
|
||||
var name = input.value.trim();
|
||||
if (name === '') {
|
||||
btnLogin.classList.add('hidden');
|
||||
btnRegister.classList.add('hidden');
|
||||
subtitle.textContent = 'Authentication';
|
||||
lastChecked = '';
|
||||
return;
|
||||
}
|
||||
if (name === lastChecked) return;
|
||||
lastChecked = name;
|
||||
|
||||
fetch('/check-username?username=' + encodeURIComponent(name))
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (input.value.trim() !== name) return;
|
||||
if (data.exists) {
|
||||
btnLogin.classList.remove('hidden');
|
||||
btnRegister.classList.add('hidden');
|
||||
subtitle.textContent = 'Welcome back';
|
||||
} else {
|
||||
btnLogin.classList.add('hidden');
|
||||
btnRegister.classList.remove('hidden');
|
||||
subtitle.textContent = 'Create your account';
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
btnLogin.classList.remove('hidden');
|
||||
btnRegister.classList.remove('hidden');
|
||||
subtitle.textContent = 'Authentication';
|
||||
});
|
||||
}
|
||||
|
||||
input.addEventListener('input', function() {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(check, 300);
|
||||
});
|
||||
|
||||
input.addEventListener('blur', function() {
|
||||
clearTimeout(timer);
|
||||
check();
|
||||
});
|
||||
|
||||
if (input.value.trim() !== '') check();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func main() {
|
||||
listen := flag.String("listen", "0.0.0.0:3000", "HTTP listen address (host:port)")
|
||||
dbPath := flag.String("db", "db/auth.db", "SQLite database path for auth users")
|
||||
secret := flag.String("secret", "", "HMAC secret for tokens (auto-generated if empty)")
|
||||
flag.Parse()
|
||||
|
||||
hmacSecret := []byte(*secret)
|
||||
if len(hmacSecret) == 0 {
|
||||
hmacSecret = make([]byte, 32)
|
||||
if _, err := rand.Read(hmacSecret); err != nil {
|
||||
log.Fatalf("generate secret: %v", err)
|
||||
}
|
||||
log.Printf("generated HMAC secret: %s", hex.EncodeToString(hmacSecret))
|
||||
log.Printf("pass --secret=%s to reuse across restarts", hex.EncodeToString(hmacSecret))
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", *dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
store, err := NewAuthStore(db)
|
||||
if err != nil {
|
||||
log.Fatalf("init auth store: %v", err)
|
||||
}
|
||||
|
||||
tok := NewTokenService(hmacSecret)
|
||||
h := NewHandlers(store, tok)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", h.HandleOAuth)
|
||||
mux.HandleFunc("/me", h.HandleMe)
|
||||
mux.HandleFunc("/check-username", h.HandleCheckUsername)
|
||||
|
||||
log.Printf("auth server listening on %s", *listen)
|
||||
if err := http.ListenAndServe(*listen, mux); err != nil {
|
||||
log.Fatalf("listen: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUserExists = errors.New("username already taken")
|
||||
ErrInvalidCreds = errors.New("invalid username or password")
|
||||
)
|
||||
|
||||
type AuthUser struct {
|
||||
ID int64
|
||||
Username string
|
||||
}
|
||||
|
||||
type AuthStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewAuthStore(db *sql.DB) (*AuthStore, error) {
|
||||
_, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS auth_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password BLOB NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create auth_users table: %w", err)
|
||||
}
|
||||
return &AuthStore{db: db}, nil
|
||||
}
|
||||
|
||||
func (s *AuthStore) CreateUser(username, password string) (AuthUser, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return AuthUser{}, fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO auth_users (username, password, created_at) VALUES (?, ?, ?)`,
|
||||
username, hash, time.Now().UTC().Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return AuthUser{}, ErrUserExists
|
||||
}
|
||||
return AuthUser{}, fmt.Errorf("insert user: %w", err)
|
||||
}
|
||||
|
||||
id, _ := res.LastInsertId()
|
||||
return AuthUser{ID: id, Username: username}, nil
|
||||
}
|
||||
|
||||
func (s *AuthStore) VerifyUser(username, password string) (AuthUser, error) {
|
||||
var id int64
|
||||
var hash []byte
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, password FROM auth_users WHERE username = ?`, username,
|
||||
).Scan(&id, &hash)
|
||||
if err != nil {
|
||||
return AuthUser{}, ErrInvalidCreds
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword(hash, []byte(password)); err != nil {
|
||||
return AuthUser{}, ErrInvalidCreds
|
||||
}
|
||||
|
||||
return AuthUser{ID: id, Username: username}, nil
|
||||
}
|
||||
|
||||
func (s *AuthStore) UserExists(username string) bool {
|
||||
var n int
|
||||
err := s.db.QueryRow(`SELECT 1 FROM auth_users WHERE username = ?`, username).Scan(&n)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func isUniqueViolation(err error) bool {
|
||||
return err != nil && (errors.Is(err, sql.ErrNoRows) ||
|
||||
// modernc.org/sqlite returns error strings containing "UNIQUE constraint failed"
|
||||
fmt.Sprintf("%v", err) == fmt.Sprintf("%v", err) &&
|
||||
contains(err.Error(), "UNIQUE constraint failed"))
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && searchStr(s, substr)
|
||||
}
|
||||
|
||||
func searchStr(s, sub string) bool {
|
||||
for i := 0; i <= len(s)-len(sub); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const tokenTTL = 24 * time.Hour
|
||||
|
||||
var (
|
||||
ErrTokenInvalid = errors.New("invalid token")
|
||||
ErrTokenExpired = errors.New("token expired")
|
||||
)
|
||||
|
||||
type TokenClaims struct {
|
||||
Sub int64 `json:"sub"`
|
||||
Name string `json:"name"`
|
||||
Iat int64 `json:"iat"`
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
|
||||
type TokenService struct {
|
||||
secret []byte
|
||||
}
|
||||
|
||||
func NewTokenService(secret []byte) *TokenService {
|
||||
return &TokenService{secret: secret}
|
||||
}
|
||||
|
||||
func (t *TokenService) Generate(user AuthUser) (string, error) {
|
||||
now := time.Now().Unix()
|
||||
claims := TokenClaims{
|
||||
Sub: user.ID,
|
||||
Name: user.Username,
|
||||
Iat: now,
|
||||
Exp: now + int64(tokenTTL.Seconds()),
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal claims: %w", err)
|
||||
}
|
||||
|
||||
enc := base64.RawURLEncoding
|
||||
payloadB64 := enc.EncodeToString(payload)
|
||||
|
||||
mac := hmac.New(sha256.New, t.secret)
|
||||
mac.Write(payload)
|
||||
sig := enc.EncodeToString(mac.Sum(nil))
|
||||
|
||||
return payloadB64 + "." + sig, nil
|
||||
}
|
||||
|
||||
func (t *TokenService) Validate(token string) (TokenClaims, error) {
|
||||
dot := -1
|
||||
for i := range token {
|
||||
if token[i] == '.' {
|
||||
dot = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if dot < 0 {
|
||||
return TokenClaims{}, ErrTokenInvalid
|
||||
}
|
||||
|
||||
payloadB64 := token[:dot]
|
||||
sigB64 := token[dot+1:]
|
||||
|
||||
enc := base64.RawURLEncoding
|
||||
|
||||
payload, err := enc.DecodeString(payloadB64)
|
||||
if err != nil {
|
||||
return TokenClaims{}, ErrTokenInvalid
|
||||
}
|
||||
|
||||
sig, err := enc.DecodeString(sigB64)
|
||||
if err != nil {
|
||||
return TokenClaims{}, ErrTokenInvalid
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, t.secret)
|
||||
mac.Write(payload)
|
||||
if !hmac.Equal(mac.Sum(nil), sig) {
|
||||
return TokenClaims{}, ErrTokenInvalid
|
||||
}
|
||||
|
||||
var claims TokenClaims
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return TokenClaims{}, ErrTokenInvalid
|
||||
}
|
||||
|
||||
if time.Now().Unix() > claims.Exp {
|
||||
return TokenClaims{}, ErrTokenExpired
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"lunar-tear/server/internal/database"
|
||||
)
|
||||
|
||||
var childTables = []string{
|
||||
"user_cage_ornament_rewards",
|
||||
"user_shop_replaceable_lineup",
|
||||
"user_shop_items",
|
||||
"user_gacha_banner_box_drew_counts",
|
||||
"user_gacha_banners",
|
||||
"user_gacha_converted_medals",
|
||||
"user_gifts",
|
||||
"user_dokan_confirmed",
|
||||
"user_drawn_omikuji",
|
||||
"user_contents_stories",
|
||||
"user_viewed_movies",
|
||||
"user_navi_cutin_played",
|
||||
"user_auto_sale_settings",
|
||||
"user_explore_scores",
|
||||
"user_tutorials",
|
||||
"user_premium_items",
|
||||
"user_important_items",
|
||||
"user_materials",
|
||||
"user_consumable_items",
|
||||
"user_gimmick_unlocks",
|
||||
"user_gimmick_sequences",
|
||||
"user_gimmick_ornament_progress",
|
||||
"user_gimmick_progress",
|
||||
"user_big_hunt_weekly_statuses",
|
||||
"user_big_hunt_weekly_max_scores",
|
||||
"user_big_hunt_schedule_max_scores",
|
||||
"user_big_hunt_statuses",
|
||||
"user_big_hunt_max_scores",
|
||||
"user_quest_limit_content_status",
|
||||
"user_side_story_quests",
|
||||
"user_missions",
|
||||
"user_quest_missions",
|
||||
"user_quests",
|
||||
"user_deck_type_notes",
|
||||
"user_deck_parts",
|
||||
"user_deck_sub_weapons",
|
||||
"user_decks",
|
||||
"user_deck_characters",
|
||||
"user_parts_presets",
|
||||
"user_parts_group_notes",
|
||||
"user_parts",
|
||||
"user_thoughts",
|
||||
"user_companions",
|
||||
"user_weapon_notes",
|
||||
"user_weapon_stories",
|
||||
"user_weapon_awakens",
|
||||
"user_weapon_abilities",
|
||||
"user_weapon_skills",
|
||||
"user_weapons",
|
||||
"user_costume_awaken_status_ups",
|
||||
"user_costume_active_skills",
|
||||
"user_costumes",
|
||||
"user_character_rebirths",
|
||||
"user_character_board_status_ups",
|
||||
"user_character_board_abilities",
|
||||
"user_character_boards",
|
||||
"user_characters",
|
||||
"user_gacha",
|
||||
"user_shop_replaceable",
|
||||
"user_explore",
|
||||
"user_guerrilla_free_open",
|
||||
"user_portal_cage",
|
||||
"user_notification",
|
||||
"user_battle",
|
||||
"user_big_hunt_state",
|
||||
"user_side_story_active",
|
||||
"user_extra_quest",
|
||||
"user_event_quest",
|
||||
"user_main_quest",
|
||||
"user_login_bonus",
|
||||
"user_login",
|
||||
"user_profile",
|
||||
"user_gem",
|
||||
"user_status",
|
||||
"user_setting",
|
||||
"sessions",
|
||||
}
|
||||
|
||||
func main() {
|
||||
dbPath := flag.String("db", "db/game.db", "SQLite database path")
|
||||
name := flag.String("name", "", "In-game player name to look up in user_profile (required)")
|
||||
flag.Parse()
|
||||
|
||||
if *name == "" {
|
||||
log.Fatal("--name flag is required")
|
||||
}
|
||||
|
||||
db, err := database.Open(*dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
var targetId int64
|
||||
err = db.QueryRow(`SELECT user_id FROM user_profile WHERE name = ?`, *name).Scan(&targetId)
|
||||
if err == sql.ErrNoRows {
|
||||
log.Fatalf("no user found with name %q", *name)
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatalf("query user_profile: %v", err)
|
||||
}
|
||||
|
||||
var targetUuid string
|
||||
err = db.QueryRow(`SELECT uuid FROM users WHERE user_id = ?`, targetId).Scan(&targetUuid)
|
||||
if err != nil {
|
||||
log.Fatalf("query target uuid: %v", err)
|
||||
}
|
||||
|
||||
var latestId int64
|
||||
var latestUuid string
|
||||
err = db.QueryRow(`SELECT user_id, uuid FROM users ORDER BY user_id DESC LIMIT 1`).Scan(&latestId, &latestUuid)
|
||||
if err != nil {
|
||||
log.Fatalf("query latest user: %v", err)
|
||||
}
|
||||
|
||||
if targetId == latestId {
|
||||
log.Printf("user %q (id=%d) is already the most recent user, nothing to do", *name, targetId)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("target: id=%d uuid=%s (name=%q)", targetId, targetUuid, *name)
|
||||
log.Printf("latest: id=%d uuid=%s (will be deleted)", latestId, latestUuid)
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
log.Fatalf("begin transaction: %v", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, t := range childTables {
|
||||
if _, err := tx.Exec(fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, t), latestId); err != nil {
|
||||
log.Fatalf("delete from %s: %v", t, err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM users WHERE user_id = ?`, latestId); err != nil {
|
||||
log.Fatalf("delete latest user: %v", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`UPDATE users SET uuid = ? WHERE user_id = ?`, latestUuid, targetId); err != nil {
|
||||
log.Fatalf("update target uuid: %v", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM sessions WHERE user_id = ?`, targetId); err != nil {
|
||||
log.Fatalf("delete stale sessions: %v", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Fatalf("commit: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("claimed account:\n")
|
||||
fmt.Printf(" user %d (%s): uuid changed %s -> %s\n", targetId, *name, targetUuid, latestUuid)
|
||||
fmt.Printf(" user %d: deleted\n", latestId)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func colorSupported() bool {
|
||||
return term.IsTerminal(int(os.Stdout.Fd()))
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func colorSupported() bool {
|
||||
fd := os.Stdout.Fd()
|
||||
if !term.IsTerminal(int(fd)) {
|
||||
return false
|
||||
}
|
||||
var mode uint32
|
||||
if windows.GetConsoleMode(windows.Handle(fd), &mode) != nil {
|
||||
return false
|
||||
}
|
||||
return windows.SetConsoleMode(windows.Handle(fd), mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) == nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
var (
|
||||
colorReset = "\033[0m"
|
||||
colorRed = "\033[31m"
|
||||
colorGreen = "\033[32m"
|
||||
colorYellow = "\033[33m"
|
||||
colorCyan = "\033[36m"
|
||||
)
|
||||
|
||||
type service struct {
|
||||
label string
|
||||
color string
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
func main() {
|
||||
// auth-server flags
|
||||
authListen := flag.String("auth.listen", "0.0.0.0:3000", "auth-server listen address (host:port)")
|
||||
authDB := flag.String("auth.db", "db/auth.db", "auth-server SQLite database path")
|
||||
|
||||
// octo-cdn flags
|
||||
cdnListen := flag.String("cdn.listen", "0.0.0.0:8080", "octo-cdn local bind address")
|
||||
cdnPublicAddr := flag.String("cdn.public-addr", "10.0.2.2:8080", "octo-cdn externally-reachable address")
|
||||
|
||||
// lunar-tear (grpc) flags
|
||||
grpcListen := flag.String("grpc.listen", "0.0.0.0:8003", "lunar-tear gRPC listen address (host:port)")
|
||||
grpcPublicAddr := flag.String("grpc.public-addr", "10.0.2.2:8003", "lunar-tear externally-reachable address")
|
||||
grpcOctoURL := flag.String("grpc.octo-url", "", "Octo CDN base URL passed to lunar-tear (default: derived from cdn.public-addr)")
|
||||
grpcAuthURL := flag.String("grpc.auth-url", "", "auth server base URL passed to lunar-tear (default: derived from auth.listen)")
|
||||
|
||||
noColor := flag.Bool("no-color", false, "disable colored output")
|
||||
flag.Parse()
|
||||
|
||||
if *grpcOctoURL == "" {
|
||||
*grpcOctoURL = fmt.Sprintf("http://%s", *cdnPublicAddr)
|
||||
}
|
||||
if *grpcAuthURL == "" {
|
||||
*grpcAuthURL = fmt.Sprintf("http://%s", *authListen)
|
||||
}
|
||||
|
||||
if *noColor || !colorSupported() {
|
||||
colorReset = ""
|
||||
colorRed = ""
|
||||
colorGreen = ""
|
||||
colorYellow = ""
|
||||
colorCyan = ""
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
services := []service{
|
||||
{
|
||||
label: "auth",
|
||||
color: colorGreen,
|
||||
cmd: exec.CommandContext(ctx, "go", "run", "./cmd/auth-server",
|
||||
"--listen", *authListen,
|
||||
"--db", *authDB,
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "cdn",
|
||||
color: colorCyan,
|
||||
cmd: exec.CommandContext(ctx, "go", "run", "./cmd/octo-cdn",
|
||||
"--listen", *cdnListen,
|
||||
"--public-addr", *cdnPublicAddr,
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "grpc",
|
||||
color: colorYellow,
|
||||
cmd: exec.CommandContext(ctx, "go", "run", "./cmd/lunar-tear",
|
||||
"--listen", *grpcListen,
|
||||
"--public-addr", *grpcPublicAddr,
|
||||
"--octo-url", *grpcOctoURL,
|
||||
"--auth-url", *grpcAuthURL,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errCh := make(chan error, len(services))
|
||||
|
||||
for i := range services {
|
||||
svc := &services[i]
|
||||
stdout, err := svc.cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
log.Fatalf("[%s] stdout pipe: %v", svc.label, err)
|
||||
}
|
||||
stderr, err := svc.cmd.StderrPipe()
|
||||
if err != nil {
|
||||
log.Fatalf("[%s] stderr pipe: %v", svc.label, err)
|
||||
}
|
||||
|
||||
if err := svc.cmd.Start(); err != nil {
|
||||
log.Fatalf("[%s] start: %v", svc.label, err)
|
||||
}
|
||||
|
||||
prefix := fmt.Sprintf("%s[%s]%s ", svc.color, svc.label, colorReset)
|
||||
wg.Add(2)
|
||||
go prefixLines(&wg, prefix, stdout)
|
||||
go prefixLines(&wg, prefix, stderr)
|
||||
|
||||
wg.Add(1)
|
||||
go func(s *service) {
|
||||
defer wg.Done()
|
||||
if err := s.cmd.Wait(); err != nil {
|
||||
errCh <- fmt.Errorf("[%s] %w", s.label, err)
|
||||
}
|
||||
}(svc)
|
||||
|
||||
log.Printf("%s%s started (pid %d)%s", svc.color, svc.label, svc.cmd.Process.Pid, colorReset)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Println("shutting down all services...")
|
||||
case err := <-errCh:
|
||||
log.Printf("%s%s%s", colorRed, err, colorReset)
|
||||
stop()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func prefixLines(wg *sync.WaitGroup, prefix string, r io.Reader) {
|
||||
defer wg.Done()
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
fmt.Printf("%s%s\n", prefix, scanner.Text())
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,20 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"strconv"
|
||||
|
||||
pb "lunar-tear/server/gen/proto"
|
||||
"lunar-tear/server/internal/gacha"
|
||||
"lunar-tear/server/internal/gametime"
|
||||
"lunar-tear/server/internal/interceptor"
|
||||
"lunar-tear/server/internal/masterdata"
|
||||
"lunar-tear/server/internal/questflow"
|
||||
"lunar-tear/server/internal/service"
|
||||
"lunar-tear/server/internal/store"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/reflection"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type loggingListener struct {
|
||||
@@ -36,9 +32,10 @@ func (l loggingListener) Accept() (net.Conn, error) {
|
||||
}
|
||||
|
||||
func startGRPC(
|
||||
host string,
|
||||
grpcPort int,
|
||||
listenAddr string,
|
||||
publicAddr string,
|
||||
octoURL string,
|
||||
authURL string,
|
||||
userStore interface {
|
||||
store.UserRepository
|
||||
store.SessionRepository
|
||||
@@ -64,23 +61,23 @@ func startGRPC(
|
||||
gameConfig *masterdata.GameConfig,
|
||||
sideStoryCatalog *masterdata.SideStoryCatalog,
|
||||
bigHuntCatalog *masterdata.BigHuntCatalog,
|
||||
) {
|
||||
addr := fmt.Sprintf(":%d", grpcPort)
|
||||
lis, err := net.Listen("tcp", addr)
|
||||
) *grpc.Server {
|
||||
lis, err := net.Listen("tcp", listenAddr)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to listen on %s: %v", addr, err)
|
||||
log.Fatalf("failed to listen on %s: %v", listenAddr, err)
|
||||
}
|
||||
lis = loggingListener{Listener: lis}
|
||||
|
||||
diffInterceptor := interceptor.NewDiffInterceptor(userStore, userStore)
|
||||
grpcServer := grpc.NewServer(
|
||||
grpc.ChainUnaryInterceptor(loggingInterceptor, timeSyncInterceptor),
|
||||
grpc.UnknownServiceHandler(loggingUnknownService),
|
||||
grpc.ChainUnaryInterceptor(interceptor.Platform, interceptor.Logging, diffInterceptor, interceptor.TimeSync),
|
||||
grpc.UnknownServiceHandler(interceptor.UnknownService),
|
||||
)
|
||||
|
||||
registerServices(grpcServer,
|
||||
host,
|
||||
grpcPort,
|
||||
publicAddr,
|
||||
octoURL,
|
||||
authURL,
|
||||
userStore,
|
||||
questEngine,
|
||||
gachaHandler,
|
||||
@@ -107,19 +104,22 @@ func startGRPC(
|
||||
|
||||
reflection.Register(grpcServer)
|
||||
|
||||
log.Printf("gRPC server listening on %s", addr)
|
||||
log.Printf("client host address: %s:%d", host, grpcPort)
|
||||
log.Printf("gRPC server listening on %s", lis.Addr())
|
||||
log.Printf("public address: %s", publicAddr)
|
||||
|
||||
if err := grpcServer.Serve(lis); err != nil {
|
||||
log.Fatalf("failed to serve: %v", err)
|
||||
}
|
||||
go func() {
|
||||
if err := grpcServer.Serve(lis); err != nil {
|
||||
log.Printf("gRPC server stopped: %v", err)
|
||||
}
|
||||
}()
|
||||
return grpcServer
|
||||
}
|
||||
|
||||
func registerServices(
|
||||
srv *grpc.Server,
|
||||
host string,
|
||||
grpcPort int,
|
||||
publicAddr string,
|
||||
octoURL string,
|
||||
authURL string,
|
||||
userStore interface {
|
||||
store.UserRepository
|
||||
store.SessionRepository
|
||||
@@ -146,10 +146,13 @@ func registerServices(
|
||||
sideStoryCatalog *masterdata.SideStoryCatalog,
|
||||
bigHuntCatalog *masterdata.BigHuntCatalog,
|
||||
) {
|
||||
pubHost, pubPortStr, _ := net.SplitHostPort(publicAddr)
|
||||
pubPort, _ := strconv.Atoi(pubPortStr)
|
||||
|
||||
pb.RegisterBannerServiceServer(srv, service.NewBannerServiceServer(gachaEntries))
|
||||
pb.RegisterUserServiceServer(srv, service.NewUserServiceServer(userStore, userStore))
|
||||
pb.RegisterUserServiceServer(srv, service.NewUserServiceServer(userStore, userStore, authURL))
|
||||
pb.RegisterBattleServiceServer(srv, service.NewBattleServiceServer(userStore, userStore))
|
||||
pb.RegisterConfigServiceServer(srv, service.NewConfigServiceServer(host, int32(grpcPort), octoURL))
|
||||
pb.RegisterConfigServiceServer(srv, service.NewConfigServiceServer(pubHost, int32(pubPort), octoURL))
|
||||
pb.RegisterDataServiceServer(srv, service.NewDataServiceServer(userStore, userStore))
|
||||
pb.RegisterTutorialServiceServer(srv, service.NewTutorialServiceServer(userStore, userStore, questEngine))
|
||||
pb.RegisterGachaServiceServer(srv, service.NewGachaServiceServer(userStore, userStore, gachaEntries, gachaHandler))
|
||||
@@ -184,39 +187,3 @@ func registerServices(
|
||||
pb.RegisterBigHuntServiceServer(srv, service.NewBigHuntServiceServer(userStore, userStore, bigHuntCatalog, questEngine))
|
||||
pb.RegisterRewardServiceServer(srv, service.NewRewardServiceServer(userStore, userStore, bigHuntCatalog, questEngine.Granter))
|
||||
}
|
||||
|
||||
func loggingInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||
log.Printf(">>> %s", info.FullMethod)
|
||||
resp, err := handler(ctx, req)
|
||||
if err != nil {
|
||||
log.Printf("<<< %s ERROR: %v", info.FullMethod, err)
|
||||
} else {
|
||||
log.Printf("<<< %s OK", info.FullMethod)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func timeSyncInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||
resp, err := handler(ctx, req)
|
||||
switch info.FullMethod {
|
||||
case "/apb.api.user.UserService/Auth",
|
||||
"/apb.api.user.UserService/RegisterUser",
|
||||
"/apb.api.user.UserService/TransferUser":
|
||||
default:
|
||||
grpc.SetTrailer(ctx, metadata.Pairs(
|
||||
"x-apb-response-datetime", fmt.Sprintf("%d", gametime.NowMillis()),
|
||||
))
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func loggingUnknownService(_ any, stream grpc.ServerStream) error {
|
||||
fullMethod, ok := grpc.MethodFromServerStream(stream)
|
||||
if !ok {
|
||||
fullMethod = "<unknown>"
|
||||
}
|
||||
log.Printf(">>> %s", fullMethod)
|
||||
err := status.Errorf(codes.Unimplemented, "unknown service or method %s", fullMethod)
|
||||
log.Printf("<<< %s ERROR: %v", fullMethod, err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"lunar-tear/server/internal/service"
|
||||
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/net/http2/h2c"
|
||||
)
|
||||
|
||||
func startHTTP(port int, resourcesBaseURL string) {
|
||||
octoServer := service.NewOctoHTTPServer(resourcesBaseURL)
|
||||
h2s := &http2.Server{}
|
||||
octoHandler := h2c.NewHandler(octoServer.Handler(), h2s)
|
||||
log.Printf("Octo HTTP server listening on :%d (HTTP/1.1 + h2c)", port)
|
||||
srv := &http.Server{Addr: fmt.Sprintf(":%d", port), Handler: octoHandler}
|
||||
http2.ConfigureServer(srv, h2s)
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
log.Fatalf("HTTP server on %d failed: %v", port, err)
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"lunar-tear/server/internal/database"
|
||||
"lunar-tear/server/internal/gacha"
|
||||
"lunar-tear/server/internal/gametime"
|
||||
"lunar-tear/server/internal/masterdata"
|
||||
"lunar-tear/server/internal/masterdata/memorydb"
|
||||
"lunar-tear/server/internal/questflow"
|
||||
"lunar-tear/server/internal/store/sqlite"
|
||||
)
|
||||
|
||||
func main() {
|
||||
httpPort := flag.Int("http-port", 8080, "HTTP server port (Octo API)")
|
||||
grpcPort := flag.Int("grpc-port", 443, "gRPC server port")
|
||||
host := flag.String("host", "127.0.0.1", "hostname the client will connect to")
|
||||
listen := flag.String("listen", "0.0.0.0:443", "gRPC listen address (host:port)")
|
||||
publicAddr := flag.String("public-addr", "127.0.0.1:443", "externally-reachable host:port advertised to clients")
|
||||
dbPath := flag.String("db", "db/game.db", "SQLite database path")
|
||||
octoURL := flag.String("octo-url", "", "Octo CDN base URL the client will use for assets (e.g. http://10.0.2.2:8080)")
|
||||
authURL := flag.String("auth-url", "", "Auth server base URL for Facebook token validation (e.g. http://localhost:3000)")
|
||||
flag.Parse()
|
||||
|
||||
octoURL := "http://" + *host + ":" + strconv.Itoa(*httpPort)
|
||||
prefix := octoURL + "/"
|
||||
padLen := 43 - len(prefix)
|
||||
resourcesBaseURL := ""
|
||||
if padLen < 1 {
|
||||
log.Printf("[config] host:port too long for 43-char resource URL; list.bin will be served unchanged")
|
||||
} else {
|
||||
resourcesBaseURL = prefix + strings.Repeat("r", padLen)
|
||||
if *octoURL == "" {
|
||||
log.Fatalf("--octo-url is required (e.g. http://10.0.2.2:8080)")
|
||||
}
|
||||
|
||||
go startHTTP(*httpPort, resourcesBaseURL)
|
||||
if err := memorydb.Init("assets/release/20240404193219.bin.e"); err != nil {
|
||||
log.Fatalf("load master data: %v", err)
|
||||
}
|
||||
log.Printf("master data loaded (%d tables)", memorydb.TableCount())
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
db, err := database.Open(*dbPath)
|
||||
if err != nil {
|
||||
@@ -164,10 +168,11 @@ func main() {
|
||||
sideStoryCatalog := masterdata.LoadSideStoryCatalog()
|
||||
bigHuntCatalog := masterdata.LoadBigHuntCatalog()
|
||||
|
||||
startGRPC(
|
||||
*host,
|
||||
*grpcPort,
|
||||
octoURL,
|
||||
grpcServer := startGRPC(
|
||||
*listen,
|
||||
*publicAddr,
|
||||
*octoURL,
|
||||
*authURL,
|
||||
userStore,
|
||||
questHandler,
|
||||
gachaHandler,
|
||||
@@ -191,4 +196,12 @@ func main() {
|
||||
sideStoryCatalog,
|
||||
bigHuntCatalog,
|
||||
)
|
||||
|
||||
<-ctx.Done()
|
||||
log.Println("shutting down...")
|
||||
|
||||
grpcServer.GracefulStop()
|
||||
database.Checkpoint(db)
|
||||
|
||||
log.Println("shutdown complete")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"lunar-tear/server/internal/service"
|
||||
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/net/http2/h2c"
|
||||
)
|
||||
|
||||
func main() {
|
||||
listen := flag.String("listen", "0.0.0.0:8080", "local bind address (host:port)")
|
||||
publicAddr := flag.String("public-addr", "127.0.0.1:8080", "externally-reachable host:port used for list.bin URL rewriting")
|
||||
assetsDir := flag.String("assets-dir", ".", "root directory containing the assets/ tree")
|
||||
flag.Parse()
|
||||
|
||||
// Build resourcesBaseURL from public-addr (must be exactly 43 chars to fit in list.bin protobuf).
|
||||
prefix := "http://" + *publicAddr + "/"
|
||||
padLen := 43 - len(prefix)
|
||||
resourcesBaseURL := ""
|
||||
if padLen < 1 {
|
||||
log.Printf("[config] public-addr too long for 43-char resource URL; list.bin will be served unchanged")
|
||||
} else {
|
||||
resourcesBaseURL = prefix + strings.Repeat("r", padLen)
|
||||
}
|
||||
|
||||
octoServer := service.NewOctoHTTPServer(resourcesBaseURL, *assetsDir)
|
||||
h2s := &http2.Server{}
|
||||
handler := h2c.NewHandler(octoServer.Handler(), h2s)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: *listen,
|
||||
Handler: handler,
|
||||
}
|
||||
http2.ConfigureServer(srv, h2s)
|
||||
|
||||
// Resolve actual listen address for logging (useful when port is 0).
|
||||
lis, err := net.Listen("tcp", *listen)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to listen on %s: %v", *listen, err)
|
||||
}
|
||||
log.Printf("Octo CDN listening on %s (HTTP/1.1 + h2c)", lis.Addr())
|
||||
log.Printf("public address: %s", *publicAddr)
|
||||
if *assetsDir != "." {
|
||||
log.Printf("assets directory: %s", *assetsDir)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
go func() {
|
||||
if err := srv.Serve(lis); err != nil && err != http.ErrServerClosed {
|
||||
fmt.Fprintf(os.Stderr, "HTTP server error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
log.Println("shutting down...")
|
||||
srv.Shutdown(context.Background())
|
||||
log.Println("shutdown complete")
|
||||
}
|
||||
Reference in New Issue
Block a user