Compare commits

4 Commits

Author SHA1 Message Date
Grail Finder
817d69c425 Enha: sse update 2025-08-03 16:13:51 +03:00
Grail Finder
acc3f11ee3 Enha: remove extra connection 2025-07-17 23:02:51 +03:00
Grail Finder
9fc36eb7ea Merge branch 'master' into enha/sse-try 2025-07-15 16:13:59 +03:00
Grail Finder
8f9865db3f Enha: simplify sse (worsened event recieving) 2025-07-12 20:33:41 +03:00
31 changed files with 412 additions and 537 deletions

View File

@@ -2,13 +2,12 @@ body{
background-color: #0C1616FF; background-color: #0C1616FF;
color: #8896b2; color: #8896b2;
min-width: 0px; min-width: 0px;
margin: 2em 2em !important; margin: 2em auto !important;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
line-height: 1.5; line-height: 1.5;
font-size: 16px; font-size: 16px;
font-family: Open Sans,Arial; font-family: Open Sans,Arial;
font-weight: bold;
text-align: center; text-align: center;
display: block; display: block;
} }
@@ -23,6 +22,18 @@ tr{
#usertable{ #usertable{
display: block ruby; display: block ruby;
} }
.actiontable{
display: inline flow-root;
margin-inline: 10px;
}
.action_name{
border: none;
display: inline;
font-family: inherit;
font-size: inherit;
padding: none;
width: auto;
}
#errorbox{ #errorbox{
border: 1px solid black; border: 1px solid black;
background-color: darkorange; background-color: darkorange;

BIN
assets/style.css.gz Normal file

Binary file not shown.

View File

@@ -146,6 +146,7 @@ rage
southwest southwest
cycle cycle
roman roman
jay
stage stage
ability ability
duration duration
@@ -544,6 +545,7 @@ norm
imaginary imaginary
pace pace
weather weather
marina
week week
scale scale
step step
@@ -883,6 +885,7 @@ ready
theorem theorem
disagreement disagreement
led led
benjamin
era era
snow snow
bowl bowl
@@ -1352,6 +1355,7 @@ male
red red
objective objective
bond bond
jimmy
opera opera
foliage foliage
motive motive
@@ -1416,6 +1420,7 @@ landscape
chin chin
sermon sermon
celebration celebration
sba
curriculum curriculum
market market
bullet bullet
@@ -1448,6 +1453,7 @@ profit
conductor conductor
elevator elevator
brutality brutality
tom
community community
hydrogen hydrogen
noon noon
@@ -1521,6 +1527,7 @@ touch
association association
abandon abandon
buddy buddy
christ
passion passion
coverage coverage
region region
@@ -1534,11 +1541,13 @@ prince
availability availability
rebel rebel
development development
pro
raise raise
sink sink
value value
discovery discovery
fly fly
warren
overhead overhead
spell spell
cross cross
@@ -1613,6 +1622,7 @@ star
improvement improvement
object object
permanent permanent
pat
carpet carpet
separation separation
sport sport
@@ -1914,6 +1924,7 @@ investment
ancient ancient
listener listener
impulse impulse
magnum
affair affair
realm realm
tea tea
@@ -1945,6 +1956,7 @@ darkness
longer longer
emotion emotion
funeral funeral
pip
there there
secondary secondary
obligation obligation
@@ -1996,6 +2008,7 @@ rail
aesthetic aesthetic
player player
porter porter
sam
singular singular
text text
tongue tongue
@@ -2106,6 +2119,7 @@ juvenile
extent extent
prayer prayer
gin gin
hogan
food food
explosion explosion
past past

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,17 @@
package broker package broker
import ( import (
"context"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"time" "time"
) )
// the amount of time to wait when pushing a message to // the amount of time to wait when pushing a message to
// a slow client or a client that closed after `range clients` started. // a slow client or a client that closed after `range clients` started.
const patience time.Duration = time.Millisecond * 500 // const patience time.Duration = time.Second * 1
type ( type (
NotificationEvent struct { NotificationEvent struct {
@@ -20,11 +22,8 @@ type (
Broker struct { Broker struct {
// Events are pushed to this channel by the main events-gathering routine // Events are pushed to this channel by the main events-gathering routine
Notifier NotifierChan Notifier NotifierChan
// New client connections log *slog.Logger
newClients chan NotifierChan addClient chan NotifierChan
// Closed client connections
closingClients chan NotifierChan
// Client connections registry
clients map[NotifierChan]struct{} clients map[NotifierChan]struct{}
} }
) )
@@ -32,10 +31,13 @@ type (
func NewBroker() (broker *Broker) { func NewBroker() (broker *Broker) {
// Instantiate a broker // Instantiate a broker
return &Broker{ return &Broker{
Notifier: make(NotifierChan, 1), Notifier: make(NotifierChan, 100),
newClients: make(chan NotifierChan), log: slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
closingClients: make(chan NotifierChan), Level: slog.LevelDebug,
clients: make(map[NotifierChan]struct{}), AddSource: true,
})),
addClient: make(chan NotifierChan, 10),
clients: map[NotifierChan]struct{}{},
} }
} }
@@ -44,7 +46,6 @@ var Notifier *Broker
// for use in different packages // for use in different packages
func init() { func init() {
Notifier = NewBroker() Notifier = NewBroker()
go Notifier.Listen()
} }
func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -59,29 +60,38 @@ func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true") w.Header().Set("Access-Control-Allow-Credentials", "true")
messageChan := make(NotifierChan, 10) // Buffered channel
broker.newClients <- messageChan
defer func() { broker.closingClients <- messageChan }()
ctx := r.Context() ctx := r.Context()
msgChan := make(NotifierChan, 10)
broker.addClient <- msgChan
// browser can close sse on its own; ping every 2s to prevent // browser can close sse on its own; ping every 2s to prevent
heartbeat := time.NewTicker(2 * time.Second) heartbeat := time.NewTicker(8 * time.Second)
defer heartbeat.Stop() defer heartbeat.Stop()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
broker.log.Debug("broker: got ctx done")
// Client disconnected // Client disconnected
return return
case event := <-messageChan: case event := <-broker.Notifier:
broker.log.Debug("got event", "event", event)
for i := 0; i < 10; i++ { // Repeat 3 times
_, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.EventName, event.Payload) _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.EventName, event.Payload)
if err != nil { if err != nil {
fmt.Println(err) broker.log.Error("write failed", "error", err)
// Client disconnected
return return
} }
w.(http.Flusher).Flush() w.(http.Flusher).Flush()
// Short delay between sends (non-blocking)
select {
case <-time.After(20 * time.Millisecond): // Adjust delay as needed
case <-ctx.Done():
return
}
}
case <-heartbeat.C: case <-heartbeat.C:
// Send SSE heartbeat comment // Send SSE heartbeat comment
if _, err := fmt.Fprint(w, ":\n\n"); err != nil { if _, err := fmt.Fprint(w, ":\n\n"); err != nil {
broker.log.Error("failed to write heartbeat", "error", err)
return // Client disconnected return // Client disconnected
} }
w.(http.Flusher).Flush() w.(http.Flusher).Flush()
@@ -89,38 +99,14 @@ func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
} }
// Listen for new notifications and redistribute them to clients func (broker *Broker) Listen(ctx context.Context) {
func (broker *Broker) Listen() {
slog.Info("Broker listener started")
for { for {
slog.Info("Broker waiting for event")
select { select {
case s := <-broker.newClients: case <-ctx.Done():
// A new client has connected. return
// Register their message channel case clientChan := <-broker.addClient:
broker.clients[s] = struct{}{} // mutex
slog.Info("Client added", "clients listening", len(broker.clients)) broker.clients[clientChan] = struct{}{}
case s := <-broker.closingClients:
// A client has dettached and we want to
// stop sending them messages.
delete(broker.clients, s)
slog.Info("Client removed", "clients listening", len(broker.clients))
case event := <-broker.Notifier:
slog.Info("Received new event", "event", event.EventName, "payload", event.Payload)
// We got a new event from the outside!
// Send event to all connected clients
slog.Info("Broadcasting event to clients", "client_count", len(broker.clients))
for clientMessageChan := range broker.clients {
slog.Info("Sending event to client", "client", clientMessageChan)
select {
case clientMessageChan <- event:
slog.Info("Successfully sent event to client", "client", clientMessageChan)
case <-time.After(patience):
delete(broker.clients, clientMessageChan)
slog.Warn("Client timed out, removed", "client", clientMessageChan, "clients listening", len(broker.clients))
}
}
slog.Info("Finished broadcasting event")
} }
} }
} }

View File

@@ -1,5 +1,5 @@
{{define "actionhistory"}} {{define "actionhistory"}}
<div id="actionHistoryContainer" class="overflow-y-auto max-h-96 border-2 border-gray-300 p-4 rounded-lg space-y-2 h-full flex-col-reverse justify-end"> <div id="actionHistoryContainer" class="overflow-y-auto max-h-96 border-2 border-gray-300 p-4 rounded-lg space-y-2">
Backlog: Backlog:
{{range .}} {{range .}}
<div class="flex items-center justify-between p-2 rounded"> <div class="flex items-center justify-between p-2 rounded">
@@ -14,4 +14,14 @@
</div> </div>
{{end}} {{end}}
</div> </div>
<script>
if (!window.actionHistoryScrollSet) {
htmx.onLoad(function(target) {
if (target.id === 'actionHistoryContainer') {
target.scrollToBottom();
}
});
window.actionHistoryScrollSet = true;
}
</script>
{{end}} {{end}}

View File

@@ -13,6 +13,12 @@
</head> </head>
<body> <body>
<div id="ancestor" hx-ext="sse" sse-connect="/sub/sse"> <div id="ancestor" hx-ext="sse" sse-connect="/sub/sse">
<script type="text/javascript">
document.body.addEventListener('htmx:sseError', function (e) {
// do something before the event data is swapped in
console.log(e)
})
</script>
<div id="main-content"> <div id="main-content">
{{template "main" .}} {{template "main" .}}
</div> </div>

View File

@@ -3,69 +3,7 @@
<div class="flex justify-center"> <div class="flex justify-center">
<div class="grid grid-cols-2 sm:grid-cols-5 gap-2"> <div class="grid grid-cols-2 sm:grid-cols-5 gap-2">
{{range .Cards}} {{range .Cards}}
{{if .Revealed}} {{template "cardword" .}}
{{if eq .Color "amber"}}
<div id="card-{{.Word}}" class="bg-{{.Color}}-100 border-8 border-stine-400 p-4 min-w-[100px] text-center text-white cursor-pointer line-through"
style="text-shadow: 0 2px 4px rgba(0,0,0,0.9);"> {{.Word}}
</div>
{{else}}
<div id="card-{{.Word}}" class="bg-{{.Color}}-400 border-8 border-stone-400 p-4 min-w-[100px] text-center text-white cursor-pointer line-through"
style="text-shadow: 0 2px 4px rgba(0,0,0,0.9);"> {{.Word}}
</div>
{{end}}
{{else if $.IsOver}}
{{if eq .Color "amber"}}
<div id="card-{{.Word}}" class="bg-{{.Color}}-100 border border-stone-400 p-4 rounded-lg min-w-[100px] text-center text-white cursor-pointer"
style="text-shadow: 0 2px 4px rgba(0,0,0,0.9);"> {{.Word}}
</div>
{{else}}
<div id="card-{{.Word}}" class="bg-{{.Color}}-400 border border-stone-400 p-4 rounded-lg min-w-[100px] text-center text-white cursor-pointer"
style="text-shadow: 0 2px 4px rgba(0,0,0,0.9);"> {{.Word}}
</div>
{{end}}
{{else if .Mime}}
{{if eq .Color "amber"}}
<div id="card-{{.Word}}" class="bg-{{.Color}}-100 border border-gray-500 rounded-lg min-w-[100px] cursor-pointer flex flex-col h-full">
{{else}}
<div id="card-{{.Word}}" class="bg-{{.Color}}-400 border border-gray-500 rounded-lg min-w-[100px] cursor-pointer flex flex-col h-full">
{{end}}
<div class="flex-grow text-center p-4 flex items-center justify-center text-white"
style="text-shadow: 0 2px 4px rgba(0,0,0,0.8);"
hx-get="/word/show-color?word={{.Word}}" hx-trigger="click" hx-swap="outerHTML transition:true swap:.05s">
{{.Word}}
</div>
<div class="h-6 bg-stone-600 rounded-b flex items-center justify-center text-white text-sm cursor-pointer"
hx-get="/mark-card?word={{.Word}}" hx-trigger="click" hx-swap="outerHTML transition:true swap:.05s">
{{range .Marks}}
{{ $length := len .Username }}
{{ if lt $length 3 }}
<span class="mx-0.5">{{.Username}}</span>
{{else}}
<span class="mx-0.5">{{slice .Username 0 3}}</span>
{{end}}
{{end}}
</div>
</div>
{{else}}
<div id="card-{{.Word}}" class="bg-stone-400 border border-gray-500 rounded-lg min-w-[100px] cursor-pointer flex flex-col h-full">
<div class="flex-grow text-center p-4 flex items-center justify-center text-white"
style="text-shadow: 0 2px 4px rgba(0,0,0,0.8);"
hx-get="/word/show-color?word={{.Word}}" hx-trigger="click" hx-swap="outerHTML transition:true swap:.05s">
{{.Word}}
</div>
<div class="h-6 bg-stone-600 rounded-b flex items-center justify-center text-white text-sm cursor-pointer"
hx-get="/mark-card?word={{.Word}}" hx-trigger="click" hx-swap="outerHTML transition:true swap:.05s">
{{range .Marks}}
{{ $length := len .Username }}
{{ if lt $length 3 }}
<span class="mx-0.5">{{.Username}}</span>
{{else}}
<span class="mx-0.5">{{slice .Username 0 3}}</span>
{{end}}
{{end}}
</div>
</div>
{{end}}
{{end}} {{end}}
</div> </div>
</div> </div>

View File

@@ -9,7 +9,7 @@
style="text-shadow: 0 2px 4px rgba(0,0,0,0.9);"> {{.Word}} style="text-shadow: 0 2px 4px rgba(0,0,0,0.9);"> {{.Word}}
</div> </div>
{{end}} {{end}}
{{else if or (.Mime) }} {{else if .Mime}}
{{if eq .Color "amber"}} {{if eq .Color "amber"}}
<div id="card-{{.Word}}" class="bg-{{.Color}}-100 border border-stone-400 p-4 rounded-lg min-w-[100px] text-center text-white cursor-pointer" <div id="card-{{.Word}}" class="bg-{{.Color}}-100 border border-stone-400 p-4 rounded-lg min-w-[100px] text-center text-white cursor-pointer"
style="text-shadow: 0 2px 4px rgba(0,0,0,0.9);"> {{.Word}} style="text-shadow: 0 2px 4px rgba(0,0,0,0.9);"> {{.Word}}

View File

@@ -4,9 +4,9 @@
Create a room <br/> Create a room <br/>
or<br/> or<br/>
<button button class="justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" hx-get="/room/hideform" hx-target=".create-room-div" >Hide Form</button> <button button class="justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" hx-get="/room/hideform" hx-target=".create-room-div" >Hide Form</button>
<form hx-post="/room-create" hx-target="#main-content" class="space-y-4"> <form hx-post="/room-create" hx-target="#main-content">
<label For="game_time">Turn Seconds:</label><br/> <label For="game_time">Turn Seconds:</label><br/>
<input type="number" id="game_time" name="game_time" class="text-center text-white" value="120"/><br/> <input type="number" id="game_time" name="game_time" class="text-center text-white" value="300"/><br/>
<label For="language">Language:</label><br/> <label For="language">Language:</label><br/>
<div> <div>
<select class="form-select text-white text-center bg-gray-900" id="languages" name="language"> <select class="form-select text-white text-center bg-gray-900" id="languages" name="language">

View File

@@ -1,6 +1,6 @@
{{define "error"}} {{define "error"}}
<a href="/"> <a href="/">
<div id=errorbox class="bg-orange-100 border-l-4 border-black text-black p-4" role="alert"> <div id=errorbox class="bg-orange-100 border-l-4 border-orange-500 text-orange-700 p-4" role="alert">
<p class="font-bold">An error from server</p> <p class="font-bold">An error from server</p>
<p>{{.}}</p> <p>{{.}}</p>
<p>Click this banner to return to main page.</p> <p>Click this banner to return to main page.</p>

View File

@@ -22,6 +22,7 @@
{{template "roomlist" .List}} {{template "roomlist" .List}}
</div> </div>
{{else}} {{else}}
<div id="sse-listener" sse-connect="/sub/sse" hx-trigger="sse:roomupdate_{{.State.RoomID}}" hx-get="/room" hx-target="#room-interier" hx-swap="none" style="display:none;"></div>
<div id="room"> <div id="room">
{{template "room" .}} {{template "room" .}}
</div> </div>

View File

@@ -1,11 +0,0 @@
{{define "journal"}}
<div id="systembox" class="overflow-y-auto max-h-96 border-2 border-gray-300 p-4 rounded-lg space-y-2">
bot journal: <br>
<ul>
{{range .LogJournal}}
<li>{{.Username}}: {{.Entry}}</li>
{{end}}
</ul>
</div>
{{end}}

View File

@@ -1,21 +1,19 @@
{{define "login"}} {{define "login"}}
<div id="logindiv"> <div id="logindiv">
<form hx-post="/login" hx-target="#main-content" class="space-y-4"> <form class="space-y-6" hx-post="/login" hx-target="#main-content">
<div> <div>
<label For="username" class="text-sm text-center font-medium leading-6 text-white-900">tell us your username (signup|login)</label> <label For="username" class="block text-sm font-medium leading-6 text-white-900">tell us your username (signup|login)</label>
<div class="mt-2"> <div class="mt-2">
<input id="username" name="username" hx-target="#login_notice" hx-swap="outerHTML" hx-post="/check/name" hx-trigger="input changed delay:400ms" autocomplete="username" required class="text-center rounded-md border-0 bg-white py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 text-center"/> <input id="username" name="username" hx-target="#login_notice" hx-swap="outerHTML" hx-post="/check/name" hx-trigger="input changed delay:400ms" autocomplete="username" required class="block w-full rounded-md border-0 bg-white py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 text-center"/>
</div> </div>
<div id="login_notice">this name looks available</div> <div id="login_notice">this name looks available</div>
</div> </div>
<div> <div>
<label For="password" class="text-sm font-medium text-center leading-6 text-white-900">password</label> <label For="password" class="block text-sm font-medium leading-6 text-white-900">password</label>
<div class="mt-2"> <input id="password" name="password" type="password" class="block w-full rounded-md border-0 bg-white py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 text-center"/>
<input id="password" name="password" type="password" class="rounded-md border-0 bg-white py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 text-center"/>
</div>
</div> </div>
<div> <div>
<button type="submit" class="justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Sign in</button> <button type="submit" class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Sign in</button>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -1,5 +1,5 @@
{{define "room"}} {{define "room"}}
<div id="interier" hx-get="/" hx-trigger="sse:roomupdate_{{.State.RoomID}}" class=space-y-2> <div id="room-interier" class=space-y-2>
<div id="headwrapper" class="grid grid-cols-1 md:grid-cols-5 md:gap-4"> <div id="headwrapper" class="grid grid-cols-1 md:grid-cols-5 md:gap-4">
<div id="meta" class="md:col-span-1 border-2 rounded-lg text-center space-y-2"> <div id="meta" class="md:col-span-1 border-2 rounded-lg text-center space-y-2">
<p>Hello {{.State.Username}};</p> <p>Hello {{.State.Username}};</p>
@@ -53,21 +53,27 @@
</div> </div>
<hr/> <hr/>
<div class="grid grid-cols-1 md:grid-cols-5 md:gap-4"> <div class="grid grid-cols-1 md:grid-cols-5 md:gap-4">
<div class="md:col-span-1"> <div hx-get="/actionhistory" class="md:col-span-1">
{{template "actionhistory" .Room.ActionHistory}} {{template "actionhistory" .Room.ActionHistory}}
</div> </div>
<div id="cardtable" class="md:col-span-3"> <div id="cardtable" class="md:col-span-3">
{{template "cardtable" .Room}} {{template "cardtable" .Room}}
</div> </div>
<div class="hidden md:block md:col-span-1"> <div class="hidden md:block md:col-span-1"></div> <!-- Spacer -->
{{template "journal" .Room}} </div>
</div> <!-- Spacer --> <div id="systembox" class="overflow-y-auto max-h-96 border-2 border-gray-300 p-4 rounded-lg space-y-2">
bot thought: <br>
<ul>
{{range .Room.LogJournal}}
<li>{{.Username}}: {{.Entry}}</li>
{{end}}
</ul>
</div> </div>
<div> <div>
{{if .Room.IsRunning}} {{if .Room.IsRunning}}
{{if and (eq .State.Role "guesser") (eq .State.Team .Room.TeamTurn) (.Room.MimeDone)}} {{if and (eq .State.Role "guesser") (eq .State.Team .Room.TeamTurn)}}
<button hx-get="/end-turn" hx-target="#room" class="bg-amber-100 text-black px-4 py-2 rounded">End Turn</button> <button hx-get="/end-turn" hx-target="#room" class="bg-amber-100 text-black px-4 py-2 rounded">End Turn</button>
{{else if and (eq .State.Role "mime") (not .Room.MimeDone) (eq .State.Team .Room.TeamTurn)}} {{else if and (eq .State.Role "mime") (not .Room.MimeDone)}}
{{template "mimeclue"}} {{template "mimeclue"}}
{{end}} {{end}}
{{end}} {{end}}

View File

@@ -2,10 +2,8 @@ package handlers
import ( import (
"crypto/hmac" "crypto/hmac"
"crypto/md5"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/hex"
"fmt" "fmt"
"gralias/models" "gralias/models"
"gralias/utils" "gralias/utils"
@@ -67,7 +65,7 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
return return
} }
username := r.PostFormValue("username") username := r.PostFormValue("username")
if username == "" || !utils.IsInputSane(username) { if username == "" {
msg := "username not provided" msg := "username not provided"
log.Error(msg) log.Error(msg)
abortWithError(w, msg) abortWithError(w, msg)
@@ -79,12 +77,6 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
// make sure username does not exists // make sure username does not exists
cleanName := utils.RemoveSpacesFromStr(username) cleanName := utils.RemoveSpacesFromStr(username)
clearPass := utils.RemoveSpacesFromStr(password) clearPass := utils.RemoveSpacesFromStr(password)
var hashedPass string
if clearPass != "" {
// hash the password with md5
hash := md5.Sum([]byte(clearPass))
hashedPass = hex.EncodeToString(hash[:])
}
// check if that user was already in db // check if that user was already in db
userstate, err := repo.PlayerGetByName(r.Context(), cleanName) userstate, err := repo.PlayerGetByName(r.Context(), cleanName)
if err != nil || userstate == nil { if err != nil || userstate == nil {
@@ -92,8 +84,8 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
userstate = models.InitPlayer(cleanName) userstate = models.InitPlayer(cleanName)
makeplayer = true makeplayer = true
} else { } else {
if userstate.Password != hashedPass { if userstate.Password != clearPass {
log.Error("wrong password", "username", cleanName) log.Error("wrong password", "username", cleanName, "password", clearPass)
abortWithError(w, "wrong password") abortWithError(w, "wrong password")
return return
} }
@@ -134,7 +126,7 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
} }
// save state to cache // save state to cache
if makeplayer { if makeplayer {
userstate.Password = hashedPass userstate.Password = clearPass
if err := repo.PlayerAdd(r.Context(), userstate); err != nil { if err := repo.PlayerAdd(r.Context(), userstate); err != nil {
log.Error("failed to save state", "error", err) log.Error("failed to save state", "error", err)
abortWithError(w, err.Error()) abortWithError(w, err.Error())

View File

@@ -213,12 +213,7 @@ func HandleMarkCard(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := validateMove(fi, models.UserRoleGuesser); err != nil { if err := validateMove(fi, models.UserRoleGuesser); err != nil {
// abortWithError(w, err.Error()) abortWithError(w, err.Error())
log.Debug("pressed mark-card out of move", "error", err)
return
}
if fi.State.Role == models.UserRoleMime {
log.Debug("mime pressed mark-card")
return return
} }
color, exists := fi.Room.FindColor(word) color, exists := fi.Room.FindColor(word)
@@ -339,3 +334,19 @@ func HandleRemoveBot(w http.ResponseWriter, r *http.Request) {
} }
notify(models.NotifyRoomUpdatePrefix+fi.Room.ID, "") notify(models.NotifyRoomUpdatePrefix+fi.Room.ID, "")
} }
func HandleGetRoom(w http.ResponseWriter, r *http.Request) {
fi, err := getFullInfoByCtx(r.Context())
if err != nil {
abortWithError(w, err.Error())
return
}
tmpl, err := template.ParseGlob("components/*.html")
if err != nil {
abortWithError(w, err.Error())
return
}
if err := tmpl.ExecuteTemplate(w, "room", fi); err != nil {
log.Error("failed to execute template", "error", err)
}
}

View File

@@ -253,16 +253,13 @@ func HandleStartGame(w http.ResponseWriter, r *http.Request) {
func HandleJoinRoom(w http.ResponseWriter, r *http.Request) { func HandleJoinRoom(w http.ResponseWriter, r *http.Request) {
roomID := r.URL.Query().Get("id") roomID := r.URL.Query().Get("id")
log.Debug("got join-room request", "id", roomID)
room, err := repo.RoomGetExtended(r.Context(), roomID) room, err := repo.RoomGetExtended(r.Context(), roomID)
if err != nil { if err != nil {
log.Error("failed to fetch room", "error", err, "room_id", roomID)
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
tmpl, err := template.ParseGlob("components/*.html") tmpl, err := template.ParseGlob("components/*.html")
if err != nil { if err != nil {
log.Error("failed to parse templates", "error", err, "room_id", roomID)
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
@@ -274,7 +271,6 @@ func HandleJoinRoom(w http.ResponseWriter, r *http.Request) {
if err := tmpl.ExecuteTemplate(w, "base", fi); err != nil { if err := tmpl.ExecuteTemplate(w, "base", fi); err != nil {
log.Error("failed to execute base template", "error", err) log.Error("failed to execute base template", "error", err)
} }
log.Error("failed to fetch fi", "error", err, "room_id", roomID)
// abortWithError(w, err.Error()) // abortWithError(w, err.Error())
return return
} }
@@ -287,7 +283,7 @@ func HandleJoinRoom(w http.ResponseWriter, r *http.Request) {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
if err := tmpl.ExecuteTemplate(w, "base", fi); err != nil { if err := tmpl.ExecuteTemplate(w, "room", fi); err != nil {
log.Error("failed to execute room template", "error", err) log.Error("failed to execute room template", "error", err)
} }
} }

View File

@@ -9,8 +9,6 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
"slices"
"strings"
) )
var ( var (
@@ -57,12 +55,6 @@ func HandleHome(w http.ResponseWriter, r *http.Request) {
} else { } else {
fi.Room.GuesserView() fi.Room.GuesserView()
} }
// reverse actions if first action
if len(fi.Room.ActionHistory) > 1 {
if strings.EqualFold(fi.Room.ActionHistory[0].Action, models.ActionTypeGameStarted) {
slices.Reverse(fi.Room.ActionHistory)
}
}
} }
if fi != nil && fi.Room == nil { if fi != nil && fi.Room == nil {
rooms, err := repo.RoomList(r.Context()) rooms, err := repo.RoomList(r.Context())
@@ -88,11 +80,6 @@ func HandleExit(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", 302) http.Redirect(w, r, "/", 302)
return return
} }
if fi.Room == nil {
log.Error("failed to fetch room")
http.Redirect(w, r, "/", 302)
return
}
if fi.Room.IsRunning { if fi.Room.IsRunning {
abortWithError(w, "cannot leave when game is running") abortWithError(w, "cannot leave when game is running")
return return
@@ -119,9 +106,8 @@ func HandleExit(w http.ResponseWriter, r *http.Request) {
if err := repo.RoomDeleteByID(r.Context(), fi.Room.ID); err != nil { if err := repo.RoomDeleteByID(r.Context(), fi.Room.ID); err != nil {
log.Error("failed to remove room", "error", err) log.Error("failed to remove room", "error", err)
} }
notify(models.NotifyRoomListUpdate, "") // why is it needed? notify(models.NotifyRoomListUpdate, "")
} else { } else {
notify(models.NotifyRoomUpdatePrefix, "")
// if regular player leaves, just exit room // if regular player leaves, just exit room
if err := repo.PlayerExitRoom(r.Context(), fi.State.Username); err != nil { if err := repo.PlayerExitRoom(r.Context(), fi.State.Username); err != nil {
log.Error("failed to exit room", "error", err) log.Error("failed to exit room", "error", err)

View File

View File

@@ -12,7 +12,7 @@ func StartTurnTimer(roomID string, timeLeft uint32) {
logger := slog.Default().With("room_id", roomID) logger := slog.Default().With("room_id", roomID)
onTurnEnd := func(ctx context.Context, roomID string) { onTurnEnd := func(ctx context.Context, roomID string) {
room, err := repo.RoomGetExtended(context.Background(), roomID) room, err := repo.RoomGetByID(context.Background(), roomID)
if err != nil { if err != nil {
logger.Error("failed to get room by id", "error", err) logger.Error("failed to get room by id", "error", err)
return return
@@ -23,7 +23,7 @@ func StartTurnTimer(roomID string, timeLeft uint32) {
if err := repo.RoomUpdate(context.Background(), room); err != nil { if err := repo.RoomUpdate(context.Background(), room); err != nil {
logger.Error("failed to save room", "error", err) logger.Error("failed to save room", "error", err)
} }
// notify(models.NotifyTurnTimerPrefix+room.ID, strconv.FormatUint(uint64(room.Settings.RoundTime), 10)) notify(models.NotifyTurnTimerPrefix+room.ID, strconv.FormatUint(uint64(room.Settings.RoundTime), 10))
notifyBotIfNeeded(room) notifyBotIfNeeded(room)
} }
@@ -31,10 +31,9 @@ func StartTurnTimer(roomID string, timeLeft uint32) {
notify(models.NotifyTurnTimerPrefix+roomID, strconv.FormatUint(uint64(currentLeft), 10)) notify(models.NotifyTurnTimerPrefix+roomID, strconv.FormatUint(uint64(currentLeft), 10))
} }
timer.StartTurnTimer(context.Background(), roomID, int32(timeLeft), onTurnEnd, onTick, logger) timer.StartTurnTimer(context.Background(), roomID, timeLeft, onTurnEnd, onTick, logger)
} }
func StopTurnTimer(roomID string) { func StopTurnTimer(roomID string) {
timer.StopTurnTimer(roomID) timer.StopTurnTimer(roomID)
} }

View File

@@ -95,52 +95,3 @@ func (b *Bot) ToPlayer() *models.Player {
IsBot: true, IsBot: true,
} }
} }
type ORModel struct {
ID string `json:"id"`
CanonicalSlug string `json:"canonical_slug"`
HuggingFaceID string `json:"hugging_face_id"`
Name string `json:"name"`
Created int `json:"created"`
Description string `json:"description"`
ContextLength int `json:"context_length"`
Architecture struct {
Modality string `json:"modality"`
InputModalities []string `json:"input_modalities"`
OutputModalities []string `json:"output_modalities"`
Tokenizer string `json:"tokenizer"`
InstructType any `json:"instruct_type"`
} `json:"architecture"`
Pricing struct {
Prompt string `json:"prompt"`
Completion string `json:"completion"`
Request string `json:"request"`
Image string `json:"image"`
Audio string `json:"audio"`
WebSearch string `json:"web_search"`
InternalReasoning string `json:"internal_reasoning"`
} `json:"pricing,omitempty"`
TopProvider struct {
ContextLength int `json:"context_length"`
MaxCompletionTokens int `json:"max_completion_tokens"`
IsModerated bool `json:"is_moderated"`
} `json:"top_provider"`
PerRequestLimits any `json:"per_request_limits"`
SupportedParameters []string `json:"supported_parameters"`
}
// https://openrouter.ai/api/v1/models
type ORModels struct {
Data []ORModel `json:"data"`
}
func (orm *ORModels) ListFree() []string {
resp := []string{}
for _, model := range orm.Data {
if model.Pricing.Prompt == "0" && model.Pricing.Request == "0" &&
model.Pricing.Completion == "0" {
resp = append(resp, model.ID)
}
}
return resp
}

View File

@@ -1,64 +0,0 @@
package llmapi
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
)
var (
ormodelsLink = "https://openrouter.ai/api/v1/models"
ORFreeModels = []string{
"google/gemini-2.0-flash-exp:free",
"deepseek/deepseek-chat-v3-0324:free",
"mistralai/mistral-small-3.2-24b-instruct:free",
"qwen/qwen3-14b:free",
"google/gemma-3-27b-it:free",
"meta-llama/llama-3.3-70b-instruct:free",
}
)
func ListORModels() ([]string, error) {
resp, err := http.Get(ormodelsLink)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
err := fmt.Errorf("failed to fetch or models; status: %s", resp.Status)
return nil, err
}
data := &ORModels{}
if err := json.NewDecoder(resp.Body).Decode(data); err != nil {
return nil, err
}
freeModels := data.ListFree()
return freeModels, nil
}
func ORModelListUpdateTicker(ctx context.Context) {
ticker := time.NewTicker(time.Hour * 2)
freeModels, err := ListORModels()
slog.Info("updated free models list", "list", freeModels)
if err != nil {
slog.Error("failed to update free models list", "list", freeModels)
}
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
freeModels, err := ListORModels()
slog.Info("updated free models list", "list", freeModels)
if err != nil {
slog.Error("failed to update free models list", "list", freeModels)
// log
continue
}
ORFreeModels = freeModels
}
}
}

View File

@@ -158,12 +158,21 @@ func (p *openRouterParser) ParseBytes(body []byte) (map[string]any, error) {
} }
func (p *openRouterParser) MakePayload(prompt string) io.Reader { func (p *openRouterParser) MakePayload(prompt string) io.Reader {
// Models to rotate through
models := []string{
"google/gemini-2.0-flash-exp:free",
"deepseek/deepseek-chat-v3-0324:free",
"mistralai/mistral-small-3.2-24b-instruct:free",
"qwen/qwen3-14b:free",
"deepseek/deepseek-r1:free",
"google/gemma-3-27b-it:free",
"meta-llama/llama-3.3-70b-instruct:free",
}
// Get next model index using atomic addition for thread safety // Get next model index using atomic addition for thread safety
p.modelIndex++ p.modelIndex++
model := ORFreeModels[int(p.modelIndex)%len(ORFreeModels)] model := models[int(p.modelIndex)%len(models)]
strPayload := fmt.Sprintf(`{ strPayload := fmt.Sprintf(`{
"model": "%s", "model": "%s",
"max_tokens": 300,
"messages": [ "messages": [
{ {
"role": "user", "role": "user",

View File

@@ -13,7 +13,7 @@ func (b *Bot) StartTurnTimer(timeLeft uint32) {
logger := b.log.With("room_id", b.RoomID) logger := b.log.With("room_id", b.RoomID)
onTurnEnd := func(ctx context.Context, roomID string) { onTurnEnd := func(ctx context.Context, roomID string) {
room, err := repos.RP.RoomGetExtended(context.Background(), roomID) room, err := repos.RP.RoomGetByID(context.Background(), roomID)
if err != nil { if err != nil {
logger.Error("failed to get room by id", "error", err) logger.Error("failed to get room by id", "error", err)
return return
@@ -24,10 +24,10 @@ func (b *Bot) StartTurnTimer(timeLeft uint32) {
if err := repos.RP.RoomUpdate(context.Background(), room); err != nil { if err := repos.RP.RoomUpdate(context.Background(), room); err != nil {
logger.Error("failed to save room", "error", err) logger.Error("failed to save room", "error", err)
} }
// broker.Notifier.Notifier <- broker.NotificationEvent{ broker.Notifier.Notifier <- broker.NotificationEvent{
// EventName: models.NotifyTurnTimerPrefix + room.ID, EventName: models.NotifyTurnTimerPrefix + room.ID,
// Payload: strconv.FormatUint(uint64(room.Settings.RoundTime), 10), Payload: strconv.FormatUint(uint64(room.Settings.RoundTime), 10),
// } }
// notifyBotIfNeeded(room) // notifyBotIfNeeded(room)
if botName := room.WhichBotToMove(); botName != "" { if botName := room.WhichBotToMove(); botName != "" {
SignalChanMap[botName] <- true SignalChanMap[botName] <- true
@@ -41,10 +41,9 @@ func (b *Bot) StartTurnTimer(timeLeft uint32) {
} }
} }
timer.StartTurnTimer(context.Background(), b.RoomID, int32(timeLeft), onTurnEnd, onTick, logger) timer.StartTurnTimer(context.Background(), b.RoomID, timeLeft, onTurnEnd, onTick, logger)
} }
func (b *Bot) StopTurnTimer() { func (b *Bot) StopTurnTimer() {
timer.StopTurnTimer(b.RoomID) timer.StopTurnTimer(b.RoomID)
} }

View File

@@ -5,7 +5,6 @@ import (
"gralias/config" "gralias/config"
"gralias/crons" "gralias/crons"
"gralias/handlers" "gralias/handlers"
"gralias/llmapi"
"gralias/repos" "gralias/repos"
"gralias/telemetry" "gralias/telemetry"
"log/slog" "log/slog"
@@ -23,7 +22,6 @@ var cfg *config.Config
func init() { func init() {
cfg = config.LoadConfigOrDefault("") cfg = config.LoadConfigOrDefault("")
go llmapi.ORModelListUpdateTicker(context.Background())
} }
// GzipFileServer serves pre-compressed .gz files if available // GzipFileServer serves pre-compressed .gz files if available
@@ -94,6 +92,7 @@ func ListenToRequests(port string) *http.Server {
mux.HandleFunc("GET /add-bot", handlers.HandleAddBot) mux.HandleFunc("GET /add-bot", handlers.HandleAddBot)
mux.HandleFunc("GET /remove-bot", handlers.HandleRemoveBot) mux.HandleFunc("GET /remove-bot", handlers.HandleRemoveBot)
mux.HandleFunc("GET /mark-card", handlers.HandleMarkCard) mux.HandleFunc("GET /mark-card", handlers.HandleMarkCard)
mux.HandleFunc("GET /room", handlers.HandleGetRoom)
// special // special
mux.HandleFunc("GET /renotify-bot", handlers.HandleRenotifyBot) mux.HandleFunc("GET /renotify-bot", handlers.HandleRenotifyBot)
// sse // sse

View File

@@ -228,20 +228,11 @@ func (r *Room) FetchLastClue() (*Action, error) {
} }
func (r *Room) FetchLastClueWord() string { func (r *Room) FetchLastClueWord() string {
if len(r.ActionHistory) > 1 {
if strings.EqualFold(r.ActionHistory[0].Action, ActionTypeGameStarted) {
for i := len(r.ActionHistory) - 1; i >= 0; i-- { for i := len(r.ActionHistory) - 1; i >= 0; i-- {
if r.ActionHistory[i].Action == string(ActionTypeClue) { if r.ActionHistory[i].Action == string(ActionTypeClue) {
return r.ActionHistory[i].Word return r.ActionHistory[i].Word
} }
} }
}
}
for i := 0; i <= len(r.ActionHistory)-1; i++ {
if r.ActionHistory[i].Action == string(ActionTypeClue) {
return r.ActionHistory[i].Word
}
}
return "" return ""
} }
@@ -419,20 +410,12 @@ func (r *Room) RevealSpecificWord(word string) uint32 {
return 0 return 0
} }
func (r *Room) SetGameOverToCards(isover bool) {
for i := range r.Cards {
r.Cards[i].IsOver = isover
}
}
type WordCard struct { type WordCard struct {
ID uint32 `json:"id" db:"id"` ID uint32 `json:"id" db:"id"`
RoomID string `json:"room_id" db:"room_id"` RoomID string `json:"room_id" db:"room_id"`
Word string `json:"word" db:"word"` Word string `json:"word" db:"word"`
Color WordColor `json:"color" db:"color"` Color WordColor `json:"color" db:"color"`
Revealed bool `json:"revealed" db:"revealed"` Revealed bool `json:"revealed" db:"revealed"`
// pain; but at the end of the game players should see color of unopen cards
IsOver bool
Mime bool `json:"mime" db:"mime_view"` // user who sees that card is mime Mime bool `json:"mime" db:"mime_view"` // user who sees that card is mime
Marks []CardMark `json:"marks" db:"-"` Marks []CardMark `json:"marks" db:"-"`
} }

View File

@@ -1,113 +0,0 @@
#!/usr/bin/env python3
"""
One-time script to migrate player passwords from plaintext to MD5 hashes.
This script:
1. Connects to the SQLite database
2. Retrieves all players with their passwords
3. Skips passwords that already look like MD5 hashes (32 hex chars)
4. Hashes plaintext passwords with MD5
5. Updates the database with hashed passwords
Usage:
python scripts/migrate_passwords.py [path_to_db]
If no path is provided, defaults to 'gralias.db' in the current directory.
"""
import hashlib
import re
import sqlite3
import sys
from pathlib import Path
def is_md5_hash(password: str) -> bool:
"""Check if password looks like an MD5 hash (32 hex characters)."""
return bool(re.match(r"^[a-fA-F0-9]{32}$", password))
def hash_password(password: str) -> str:
"""Hash password with MD5."""
return hashlib.md5(password.encode("utf-8")).hexdigest()
def migrate_passwords(db_path: str) -> None:
"""Migrate all player passwords to MD5 hashes."""
print(f"Connecting to database: {db_path}")
if not Path(db_path).exists():
print(f"Error: Database file not found: {db_path}")
sys.exit(1)
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
# Get all players
cursor.execute("SELECT username, password FROM players;")
players = cursor.fetchall()
if not players:
print("No players found in database.")
return
print(f"Found {len(players)} player(s)")
print("-" * 60)
migrated = 0
skipped = 0
errors = 0
for username, password in players:
if not password:
print(f"[SKIP] {username}: empty password")
skipped += 1
continue
if is_md5_hash(password):
print(f"[SKIP] {username}: already MD5 hashed")
skipped += 1
continue
# Hash the password
hashed = hash_password(password)
try:
cursor.execute(
"UPDATE players SET password = ? WHERE username = ?;",
(hashed, username),
)
print(f"[MIGRATED] {username}: '{password}' -> '{hashed}'")
migrated += 1
except sqlite3.Error as e:
print(f"[ERROR] {username}: {e}")
errors += 1
print("-" * 60)
print(f"Summary: {migrated} migrated, {skipped} skipped, {errors} errors")
# Commit changes
conn.commit()
print("Changes committed to database.")
except sqlite3.Error as e:
print(f"Database error: {e}")
conn.rollback()
sys.exit(1)
finally:
conn.close()
def main():
# Get database path from command line or use default
if len(sys.argv) > 1:
db_path = sys.argv[1]
else:
db_path = "gralias.db"
migrate_passwords(db_path)
if __name__ == "__main__":
main()

View File

@@ -28,7 +28,7 @@ var (
) )
// StartTurnTimer initializes and starts a new turn timer for a given room. // StartTurnTimer initializes and starts a new turn timer for a given room.
func StartTurnTimer(ctx context.Context, roomID string, timeLeft int32, onTurnEnd TurnEndCallback, onTick TickCallback, logger *slog.Logger) { func StartTurnTimer(ctx context.Context, roomID string, timeLeft uint32, onTurnEnd TurnEndCallback, onTick TickCallback, logger *slog.Logger) {
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
@@ -62,7 +62,7 @@ func StartTurnTimer(ctx context.Context, roomID string, timeLeft int32, onTurnEn
StopTurnTimer(roomID) StopTurnTimer(roomID)
return return
} }
rt.onTick(ctx, roomID, uint32(currentLeft)) rt.onTick(ctx, roomID, currentLeft)
currentLeft-- currentLeft--
} }
} }

View File

@@ -36,9 +36,6 @@
- player stats: played games, lost, won, rating elo, opened opposite words, opened white words, opened black words. + - player stats: played games, lost, won, rating elo, opened opposite words, opened white words, opened black words. +
- at the end of the game, all colors should be revealed; - at the end of the game, all colors should be revealed;
- tracing; - tracing;
====
- action history to bot (not json); basic stuff: what words were previously given as clue and guessed (maybe what team bot is on);
- automate getting list of free and non-thinking openrouter models;
#### sse points #### sse points
- clue sse update; - clue sse update;
@@ -96,16 +93,3 @@
- journal still does not work; + - journal still does not work; +
- lose/win game; then exit room (while being the creator), then press to stats -> cannot find session in db, although cookie in place and session in db; + - lose/win game; then exit room (while being the creator), then press to stats -> cannot find session in db, although cookie in place and session in db; +
- exit endpoints delets player from db; + - exit endpoints delets player from db; +
- timer end did not update the page;
- timer did not change turn (guesser did not manage to guess 1 word);
- timers conflict; stop timers;
- clue snatching;
- llm resp token amount limit;
=============
- autoscroll backlog to the last action; (reversed order) +
- mimes to see marks on the words; +
- clearer ways to see opened words; (line through) +
- guesser sees end turn button before clue was given by mime; +
- sql no rows when joining by link?

View File

@@ -5,10 +5,6 @@ import (
"unicode" "unicode"
) )
const (
SaneLengthMax = 20
)
func RemoveSpacesFromStr(origin string) string { func RemoveSpacesFromStr(origin string) string {
return strings.Map(func(r rune) rune { return strings.Map(func(r rune) rune {
if unicode.IsSpace(r) { if unicode.IsSpace(r) {
@@ -39,13 +35,3 @@ func RemoveFromSlice(key string, sl []string) []string {
} }
return resp return resp
} }
func IsInputSane(s string) bool {
if len(s) > SaneLengthMax {
return false
}
if strings.ContainsAny(s, "{}?!$&:/[]~") {
return false
}
return true
}