Merge branch 'dev' into feature/qbittorrent-widget
@ -1 +1,2 @@
|
|||||||
github: [glanceapp]
|
github: [glanceapp]
|
||||||
|
patreon: glanceapp
|
||||||
|
|||||||
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 792 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
@ -1,32 +1,33 @@
|
|||||||
module github.com/glanceapp/glance
|
module github.com/glanceapp/glance
|
||||||
|
|
||||||
go 1.23.6
|
go 1.24.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/fsnotify/fsnotify v1.8.0
|
github.com/fsnotify/fsnotify v1.9.0
|
||||||
github.com/mmcdole/gofeed v1.3.0
|
github.com/mmcdole/gofeed v1.3.0
|
||||||
github.com/shirou/gopsutil/v4 v4.25.1
|
github.com/shirou/gopsutil/v4 v4.25.5
|
||||||
github.com/tidwall/gjson v1.18.0
|
github.com/tidwall/gjson v1.18.0
|
||||||
golang.org/x/text v0.22.0
|
golang.org/x/crypto v0.39.0
|
||||||
|
golang.org/x/text v0.26.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/PuerkitoBio/goquery v1.10.1 // indirect
|
github.com/PuerkitoBio/goquery v1.10.3 // indirect
|
||||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||||
github.com/ebitengine/purego v0.8.2 // indirect
|
github.com/ebitengine/purego v0.8.4 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect
|
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
|
||||||
github.com/mmcdole/goxpp v1.1.1 // indirect
|
github.com/mmcdole/goxpp v1.1.1 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
github.com/tidwall/pretty v1.2.1 // indirect
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.14 // indirect
|
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||||
github.com/tklauser/numcpus v0.9.0 // indirect
|
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/net v0.34.0 // indirect
|
golang.org/x/net v0.41.0 // indirect
|
||||||
golang.org/x/sys v0.30.0 // indirect
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@ -0,0 +1,343 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
mathrand "math/rand/v2"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const AUTH_SESSION_COOKIE_NAME = "session_token"
|
||||||
|
const AUTH_RATE_LIMIT_WINDOW = 5 * time.Minute
|
||||||
|
const AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5
|
||||||
|
|
||||||
|
const AUTH_TOKEN_SECRET_LENGTH = 32
|
||||||
|
const AUTH_USERNAME_HASH_LENGTH = 32
|
||||||
|
const AUTH_SECRET_KEY_LENGTH = AUTH_TOKEN_SECRET_LENGTH + AUTH_USERNAME_HASH_LENGTH
|
||||||
|
const AUTH_TIMESTAMP_LENGTH = 4 // uint32
|
||||||
|
const AUTH_TOKEN_DATA_LENGTH = AUTH_USERNAME_HASH_LENGTH + AUTH_TIMESTAMP_LENGTH
|
||||||
|
|
||||||
|
// How long the token will be valid for
|
||||||
|
const AUTH_TOKEN_VALID_PERIOD = 14 * 24 * time.Hour // 14 days
|
||||||
|
// How long the token has left before it should be regenerated
|
||||||
|
const AUTH_TOKEN_REGEN_BEFORE = 7 * 24 * time.Hour // 7 days
|
||||||
|
|
||||||
|
var loginPageTemplate = mustParseTemplate("login.html", "document.html", "footer.html")
|
||||||
|
|
||||||
|
type doWhenUnauthorized int
|
||||||
|
|
||||||
|
const (
|
||||||
|
redirectToLogin doWhenUnauthorized = iota
|
||||||
|
showUnauthorizedJSON
|
||||||
|
)
|
||||||
|
|
||||||
|
type failedAuthAttempt struct {
|
||||||
|
attempts int
|
||||||
|
first time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateSessionToken(username string, secret []byte, now time.Time) (string, error) {
|
||||||
|
if len(secret) != AUTH_SECRET_KEY_LENGTH {
|
||||||
|
return "", fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
usernameHash, err := computeUsernameHash(username, secret)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
data := make([]byte, AUTH_TOKEN_DATA_LENGTH)
|
||||||
|
copy(data, usernameHash)
|
||||||
|
expires := now.Add(AUTH_TOKEN_VALID_PERIOD).Unix()
|
||||||
|
binary.LittleEndian.PutUint32(data[AUTH_USERNAME_HASH_LENGTH:], uint32(expires))
|
||||||
|
|
||||||
|
h := hmac.New(sha256.New, secret[0:AUTH_TOKEN_SECRET_LENGTH])
|
||||||
|
h.Write(data)
|
||||||
|
|
||||||
|
signature := h.Sum(nil)
|
||||||
|
encodedToken := base64.StdEncoding.EncodeToString(append(data, signature...))
|
||||||
|
// encodedToken ends up being (hashed username + expiration timestamp + signature) encoded as base64
|
||||||
|
|
||||||
|
return encodedToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeUsernameHash(username string, secret []byte) ([]byte, error) {
|
||||||
|
if len(secret) != AUTH_SECRET_KEY_LENGTH {
|
||||||
|
return nil, fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := hmac.New(sha256.New, secret[AUTH_TOKEN_SECRET_LENGTH:])
|
||||||
|
h.Write([]byte(username))
|
||||||
|
|
||||||
|
return h.Sum(nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifySessionToken(token string, secretBytes []byte, now time.Time) ([]byte, bool, error) {
|
||||||
|
tokenBytes, err := base64.StdEncoding.DecodeString(token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tokenBytes) != AUTH_TOKEN_DATA_LENGTH+32 {
|
||||||
|
return nil, false, fmt.Errorf("token length is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(secretBytes) != AUTH_SECRET_KEY_LENGTH {
|
||||||
|
return nil, false, fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
usernameHashBytes := tokenBytes[0:AUTH_USERNAME_HASH_LENGTH]
|
||||||
|
timestampBytes := tokenBytes[AUTH_USERNAME_HASH_LENGTH : AUTH_USERNAME_HASH_LENGTH+AUTH_TIMESTAMP_LENGTH]
|
||||||
|
providedSignatureBytes := tokenBytes[AUTH_TOKEN_DATA_LENGTH:]
|
||||||
|
|
||||||
|
h := hmac.New(sha256.New, secretBytes[0:32])
|
||||||
|
h.Write(tokenBytes[0:AUTH_TOKEN_DATA_LENGTH])
|
||||||
|
expectedSignatureBytes := h.Sum(nil)
|
||||||
|
|
||||||
|
if !hmac.Equal(expectedSignatureBytes, providedSignatureBytes) {
|
||||||
|
return nil, false, fmt.Errorf("signature does not match")
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresTimestamp := int64(binary.LittleEndian.Uint32(timestampBytes))
|
||||||
|
if now.Unix() > expiresTimestamp {
|
||||||
|
return nil, false, fmt.Errorf("token has expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
return usernameHashBytes,
|
||||||
|
// True if the token should be regenerated
|
||||||
|
time.Unix(expiresTimestamp, 0).Add(-AUTH_TOKEN_REGEN_BEFORE).Before(now),
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeAuthSecretKey(length int) (string, error) {
|
||||||
|
key := make([]byte, length)
|
||||||
|
_, err := rand.Read(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(key), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) handleAuthenticationAttempt(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Content-Type") != "application/json" {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
waitOnFailure := 1*time.Second - time.Duration(mathrand.IntN(500))*time.Millisecond
|
||||||
|
|
||||||
|
ip := a.addressOfRequest(r)
|
||||||
|
|
||||||
|
a.authAttemptsMu.Lock()
|
||||||
|
exceededRateLimit, retryAfter := func() (bool, int) {
|
||||||
|
attempt, exists := a.failedAuthAttempts[ip]
|
||||||
|
if !exists {
|
||||||
|
a.failedAuthAttempts[ip] = &failedAuthAttempt{
|
||||||
|
attempts: 1,
|
||||||
|
first: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := time.Since(attempt.first)
|
||||||
|
if elapsed < AUTH_RATE_LIMIT_WINDOW && attempt.attempts >= AUTH_RATE_LIMIT_MAX_ATTEMPTS {
|
||||||
|
return true, max(1, int(AUTH_RATE_LIMIT_WINDOW.Seconds()-elapsed.Seconds()))
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt.attempts++
|
||||||
|
return false, 0
|
||||||
|
}()
|
||||||
|
|
||||||
|
if exceededRateLimit {
|
||||||
|
a.authAttemptsMu.Unlock()
|
||||||
|
time.Sleep(waitOnFailure)
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
// Clean up old failed attempts
|
||||||
|
for ipOfAttempt := range a.failedAuthAttempts {
|
||||||
|
if time.Since(a.failedAuthAttempts[ipOfAttempt].first) > AUTH_RATE_LIMIT_WINDOW {
|
||||||
|
delete(a.failedAuthAttempts, ipOfAttempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.authAttemptsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var creds struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(body, &creds)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logAuthFailure := func() {
|
||||||
|
log.Printf(
|
||||||
|
"Failed login attempt for user '%s' from %s",
|
||||||
|
creds.Username, ip,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(creds.Username) == 0 || len(creds.Password) == 0 {
|
||||||
|
time.Sleep(waitOnFailure)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(creds.Username) > 50 || len(creds.Password) > 100 {
|
||||||
|
logAuthFailure()
|
||||||
|
time.Sleep(waitOnFailure)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, exists := a.Config.Auth.Users[creds.Username]
|
||||||
|
if !exists {
|
||||||
|
logAuthFailure()
|
||||||
|
time.Sleep(waitOnFailure)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword(u.PasswordHash, []byte(creds.Password)); err != nil {
|
||||||
|
logAuthFailure()
|
||||||
|
time.Sleep(waitOnFailure)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := generateSessionToken(creds.Username, a.authSecretKey, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Could not compute session token during login attempt: %v", err)
|
||||||
|
time.Sleep(waitOnFailure)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.setAuthSessionCookie(w, r, token, time.Now().Add(AUTH_TOKEN_VALID_PERIOD))
|
||||||
|
|
||||||
|
a.authAttemptsMu.Lock()
|
||||||
|
delete(a.failedAuthAttempts, ip)
|
||||||
|
a.authAttemptsMu.Unlock()
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) isAuthorized(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
if !a.RequiresAuth {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := r.Cookie(AUTH_SESSION_COOKIE_NAME)
|
||||||
|
if err != nil || token.Value == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
usernameHash, shouldRegenerate, err := verifySessionToken(token.Value, a.authSecretKey, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
username, exists := a.usernameHashToUsername[string(usernameHash)]
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
_, exists = a.Config.Auth.Users[username]
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldRegenerate {
|
||||||
|
newToken, err := generateSessionToken(username, a.authSecretKey, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Could not compute session token during regeneration: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
a.setAuthSessionCookie(w, r, newToken, time.Now().Add(AUTH_TOKEN_VALID_PERIOD))
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handles sending the appropriate response for an unauthorized request and returns true if the request was unauthorized
|
||||||
|
func (a *application) handleUnauthorizedResponse(w http.ResponseWriter, r *http.Request, fallback doWhenUnauthorized) bool {
|
||||||
|
if a.isAuthorized(w, r) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch fallback {
|
||||||
|
case redirectToLogin:
|
||||||
|
http.Redirect(w, r, a.Config.Server.BaseURL+"/login", http.StatusSeeOther)
|
||||||
|
case showUnauthorizedJSON:
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
w.Write([]byte(`{"error": "Unauthorized"}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maybe this should be a POST request instead?
|
||||||
|
func (a *application) handleLogoutRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.setAuthSessionCookie(w, r, "", time.Now().Add(-1*time.Hour))
|
||||||
|
http.Redirect(w, r, a.Config.Server.BaseURL+"/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) setAuthSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: AUTH_SESSION_COOKIE_NAME,
|
||||||
|
Value: token,
|
||||||
|
Expires: expires,
|
||||||
|
Secure: strings.ToLower(r.Header.Get("X-Forwarded-Proto")) == "https",
|
||||||
|
Path: a.Config.Server.BaseURL + "/",
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
HttpOnly: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) handleLoginPageRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if a.isAuthorized(w, r) {
|
||||||
|
http.Redirect(w, r, a.Config.Server.BaseURL+"/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := &templateData{
|
||||||
|
App: a,
|
||||||
|
}
|
||||||
|
a.populateTemplateRequestData(&data.Request, r)
|
||||||
|
|
||||||
|
var responseBytes bytes.Buffer
|
||||||
|
err := loginPageTemplate.Execute(&responseBytes, data)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Write(responseBytes.Bytes())
|
||||||
|
}
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAuthTokenGenerationAndVerification(t *testing.T) {
|
||||||
|
secret, err := makeAuthSecretKey(AUTH_SECRET_KEY_LENGTH)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to generate secret key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secretBytes, err := base64.StdEncoding.DecodeString(secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to decode secret key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(secretBytes) != AUTH_SECRET_KEY_LENGTH {
|
||||||
|
t.Fatalf("Secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
username := "admin"
|
||||||
|
|
||||||
|
token, err := generateSessionToken(username, secretBytes, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to generate session token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
usernameHashBytes, shouldRegen, err := verifySessionToken(token, secretBytes, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to verify session token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldRegen {
|
||||||
|
t.Fatal("Token should not need to be regenerated immediately after generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
computedUsernameHash, err := computeUsernameHash(username, secretBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to compute username hash: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(usernameHashBytes, computedUsernameHash) {
|
||||||
|
t.Fatal("Username hash does not match the expected value")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test token regeneration
|
||||||
|
timeRightAfterRegenPeriod := now.Add(AUTH_TOKEN_VALID_PERIOD - AUTH_TOKEN_REGEN_BEFORE + 2*time.Second)
|
||||||
|
_, shouldRegen, err = verifySessionToken(token, secretBytes, timeRightAfterRegenPeriod)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Token verification should not fail during regeneration period, err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !shouldRegen {
|
||||||
|
t.Fatal("Token should have been marked for regeneration")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test token expiration
|
||||||
|
_, _, err = verifySessionToken(token, secretBytes, now.Add(AUTH_TOKEN_VALID_PERIOD+2*time.Second))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected token verification to fail after token expiration")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test tampered token
|
||||||
|
decodedToken, err := base64.StdEncoding.DecodeString(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to decode token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If any of the bytes are off by 1, the token should be considered invalid
|
||||||
|
for i := range len(decodedToken) {
|
||||||
|
tampered := make([]byte, len(decodedToken))
|
||||||
|
copy(tampered, decodedToken)
|
||||||
|
tampered[i] += 1
|
||||||
|
|
||||||
|
_, _, err = verifySessionToken(base64.StdEncoding.EncodeToString(tampered), secretBytes, now)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("Expected token verification to fail for tampered token at index %d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 8.2 KiB |
@ -0,0 +1,155 @@
|
|||||||
|
.login-bounds {
|
||||||
|
max-width: 500px;
|
||||||
|
padding: 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
transition: border-color .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input input {
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 5.2rem;
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input-icon {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
margin-top: -0.1rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input input[type="password"] {
|
||||||
|
letter-spacing: 0.3rem;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input input[type="password"]::placeholder {
|
||||||
|
letter-spacing: 0;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:hover {
|
||||||
|
border-color: var(--color-progress-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus-within {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
transition-duration: .7s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
padding: 1rem;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-text-subdue);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
color: var(--color-text-paragraph);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--font-size-h4);
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all .3s, margin-top 0s;
|
||||||
|
margin-top: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:not(:disabled) {
|
||||||
|
box-shadow: 0 0 10px 1px var(--color-separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error-message:not(:empty) + .login-button {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:focus, .login-button:hover {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:disabled {
|
||||||
|
border-color: var(--color-separator);
|
||||||
|
color: var(--color-text-subdue);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button svg {
|
||||||
|
width: 1.7rem;
|
||||||
|
height: 1.7rem;
|
||||||
|
transition: transform .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:not(:disabled):hover svg, .login-button:not(:disabled):focus svg {
|
||||||
|
transform: translateX(.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-entrance {
|
||||||
|
animation: fieldReveal 0.7s backwards;
|
||||||
|
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-entrance:nth-child(1) { animation-delay: .1s; }
|
||||||
|
.animate-entrance:nth-child(2) { animation-delay: .2s; }
|
||||||
|
.animate-entrance:nth-child(4) { animation-delay: .3s; }
|
||||||
|
|
||||||
|
@keyframes fieldReveal {
|
||||||
|
from {
|
||||||
|
opacity: 0.0001;
|
||||||
|
transform: translateY(4rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error-message {
|
||||||
|
color: var(--color-negative);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
padding: 1.3rem calc(var(--widget-content-horizontal-padding) + 1px);
|
||||||
|
position: relative;
|
||||||
|
margin-top: 2rem;
|
||||||
|
animation: errorMessageEntrance 0.4s backwards cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes errorMessageEntrance {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error-message:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error-message::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background: var(--color-negative);
|
||||||
|
opacity: 0.05;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
animation-delay: .4s;
|
||||||
|
animation-duration: 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-password-visibility {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
@ -1,26 +0,0 @@
|
|||||||
.docker-container-icon {
|
|
||||||
display: block;
|
|
||||||
filter: grayscale(0.4);
|
|
||||||
object-fit: contain;
|
|
||||||
aspect-ratio: 1 / 1;
|
|
||||||
width: 2.7rem;
|
|
||||||
opacity: 0.8;
|
|
||||||
transition: filter 0.3s, opacity 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.docker-container-icon.flat-icon {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.docker-container:hover .docker-container-icon {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.docker-container:hover .docker-container-icon:not(.flat-icon) {
|
|
||||||
filter: grayscale(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.docker-container-status-icon {
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
}
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
.monitor-site-icon {
|
|
||||||
display: block;
|
|
||||||
opacity: 0.8;
|
|
||||||
filter: grayscale(0.4);
|
|
||||||
object-fit: contain;
|
|
||||||
aspect-ratio: 1 / 1;
|
|
||||||
width: 3.2rem;
|
|
||||||
position: relative;
|
|
||||||
top: -0.1rem;
|
|
||||||
transition: filter 0.3s, opacity 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.monitor-site-icon.flat-icon {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.monitor-site:hover .monitor-site-icon {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.monitor-site:hover .monitor-site-icon:not(.flat-icon) {
|
|
||||||
filter: grayscale(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.monitor-site-status-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-left: auto;
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.monitor-site-status-icon-compact {
|
|
||||||
width: 1.8rem;
|
|
||||||
height: 1.8rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
@ -0,0 +1,129 @@
|
|||||||
|
.todo-widget {
|
||||||
|
padding-top: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-plus-icon {
|
||||||
|
--icon-color: var(--color-text-subdue);
|
||||||
|
position: relative;
|
||||||
|
width: 1.4rem;
|
||||||
|
height: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-plus-icon::before, .todo-plus-icon::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
background-color: var(--icon-color);
|
||||||
|
transition: background-color .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-plus-icon::before {
|
||||||
|
width: 2px;
|
||||||
|
inset-block: 0.2rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-plus-icon::after {
|
||||||
|
height: 2px;
|
||||||
|
inset-inline: 0.2rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-input textarea::placeholder {
|
||||||
|
color: var(--color-text-base-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-input {
|
||||||
|
position: relative;
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-input:focus-within .todo-plus-icon {
|
||||||
|
--icon-color: var(--color-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item {
|
||||||
|
transform-origin: center;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-checkbox {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
border: 2px solid var(--color-text-subdue);
|
||||||
|
width: 1.4rem;
|
||||||
|
height: 1.4rem;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
transition: border-color .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-checkbox::before {
|
||||||
|
content: "";
|
||||||
|
inset: -1rem;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-checkbox::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0.3rem;
|
||||||
|
border-radius: 0.1rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-checkbox:checked::after {
|
||||||
|
background: var(--color-primary);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-checkbox:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-text {
|
||||||
|
color: var(--color-text-base);
|
||||||
|
transition: color .35s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-text:focus {
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-drag-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: -0.5rem;
|
||||||
|
inset-inline: 0;
|
||||||
|
height: 1rem;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item.is-being-dragged .todo-item-drag-handle {
|
||||||
|
height: 3rem;
|
||||||
|
top: -1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item:has(.todo-item-checkbox:checked) .todo-item-text {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--color-text-subdue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-delete {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .2s;
|
||||||
|
outline-offset: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item:hover .todo-item-delete, .todo-item:focus-within .todo-item-delete {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item.is-being-dragged .todo-item-delete {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 607 B |
@ -0,0 +1,7 @@
|
|||||||
|
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="26" height="26" rx="3" fill="#151519"/>
|
||||||
|
<rect x="2" y="2" width="10" height="22" rx="2" fill="#ededed"/>
|
||||||
|
<rect x="14" y="2" width="10" height="10" rx="2" fill="#ededed"/>
|
||||||
|
<path d="M16.3018 5.04032L17.328 4H22V8.72984L20.9014 9.81855V6.49193C20.9014 6.35484 20.9095 6.21774 20.9256 6.08065C20.9497 5.93548 20.9859 5.81855 21.0342 5.72984L16.7847 10L16 9.2379L20.3099 4.93145C20.2294 4.97984 20.1167 5.0121 19.9718 5.02823C19.827 5.03629 19.674 5.04032 19.5131 5.04032H16.3018Z" fill="#151519"/>
|
||||||
|
<rect x="14" y="14" width="10" height="10" rx="2" fill="#ededed"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 677 B |
@ -0,0 +1,141 @@
|
|||||||
|
import { find } from "./templating.js";
|
||||||
|
|
||||||
|
const AUTH_ENDPOINT = pageData.baseURL + "/api/authenticate";
|
||||||
|
|
||||||
|
const showPasswordSVG = `<svg class="form-input-icon" stroke="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
const hidePasswordSVG = `<svg class="form-input-icon" stroke="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
const container = find("#login-container");
|
||||||
|
const usernameInput = find("#username");
|
||||||
|
const passwordInput = find("#password");
|
||||||
|
const errorMessage = find("#error-message");
|
||||||
|
const loginButton = find("#login-button");
|
||||||
|
const toggleVisibilityButton = find("#toggle-password-visibility");
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
lastUsername: "",
|
||||||
|
lastPassword: "",
|
||||||
|
isLoading: false,
|
||||||
|
isRateLimited: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const lang = {
|
||||||
|
showPassword: "Show password",
|
||||||
|
hidePassword: "Hide password",
|
||||||
|
incorrectCredentials: "Incorrect username or password",
|
||||||
|
rateLimited: "Too many login attempts, try again in a few minutes",
|
||||||
|
unknownError: "An error occurred, please try again",
|
||||||
|
};
|
||||||
|
|
||||||
|
container.clearStyles("display");
|
||||||
|
setTimeout(() => usernameInput.focus(), 200);
|
||||||
|
|
||||||
|
toggleVisibilityButton
|
||||||
|
.html(showPasswordSVG)
|
||||||
|
.attr("title", lang.showPassword)
|
||||||
|
.on("click", function() {
|
||||||
|
if (passwordInput.type === "password") {
|
||||||
|
passwordInput.type = "text";
|
||||||
|
toggleVisibilityButton.html(hidePasswordSVG).attr("title", lang.hidePassword);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordInput.type = "password";
|
||||||
|
toggleVisibilityButton.html(showPasswordSVG).attr("title", lang.showPassword);
|
||||||
|
});
|
||||||
|
|
||||||
|
function enableLoginButtonIfCriteriaMet() {
|
||||||
|
const usernameValue = usernameInput.value.trim();
|
||||||
|
const passwordValue = passwordInput.value.trim();
|
||||||
|
|
||||||
|
const usernameValid = usernameValue.length >= 3;
|
||||||
|
const passwordValid = passwordValue.length >= 6;
|
||||||
|
|
||||||
|
const isUsingLastCredentials =
|
||||||
|
usernameValue === state.lastUsername
|
||||||
|
&& passwordValue === state.lastPassword;
|
||||||
|
|
||||||
|
loginButton.disabled = !(
|
||||||
|
usernameValid
|
||||||
|
&& passwordValid
|
||||||
|
&& !isUsingLastCredentials
|
||||||
|
&& !state.isLoading
|
||||||
|
&& !state.isRateLimited
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLoginWithEnter(event) {
|
||||||
|
if (event.key !== "Enter") return;
|
||||||
|
if (loginButton.disabled) return;
|
||||||
|
|
||||||
|
document.activeElement.blur();
|
||||||
|
handleLoginAttempt();
|
||||||
|
}
|
||||||
|
|
||||||
|
usernameInput
|
||||||
|
.on("input", enableLoginButtonIfCriteriaMet)
|
||||||
|
.on("keydown", handleLoginWithEnter);
|
||||||
|
|
||||||
|
passwordInput
|
||||||
|
.on("input", enableLoginButtonIfCriteriaMet)
|
||||||
|
.on("keydown", handleLoginWithEnter);
|
||||||
|
|
||||||
|
async function handleLoginAttempt() {
|
||||||
|
state.lastUsername = usernameInput.value;
|
||||||
|
state.lastPassword = passwordInput.value;
|
||||||
|
errorMessage.text("");
|
||||||
|
|
||||||
|
loginButton.disable();
|
||||||
|
state.isLoading = true;
|
||||||
|
|
||||||
|
const response = await fetch(AUTH_ENDPOINT, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: usernameInput.value,
|
||||||
|
password: passwordInput.value
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
state.isLoading = false;
|
||||||
|
if (response.status === 200) {
|
||||||
|
setTimeout(() => { window.location.href = pageData.baseURL + "/"; }, 300);
|
||||||
|
|
||||||
|
container.animate({
|
||||||
|
keyframes: [{ offset: 1, transform: "scale(0.95)", opacity: 0 }],
|
||||||
|
options: { duration: 300, easing: "ease", fill: "forwards" }}
|
||||||
|
);
|
||||||
|
|
||||||
|
find("footer")?.animate({
|
||||||
|
keyframes: [{ offset: 1, opacity: 0 }],
|
||||||
|
options: { duration: 300, easing: "ease", fill: "forwards", delay: 50 }
|
||||||
|
});
|
||||||
|
} else if (response.status === 401) {
|
||||||
|
errorMessage.text(lang.incorrectCredentials);
|
||||||
|
passwordInput.focus();
|
||||||
|
} else if (response.status === 429) {
|
||||||
|
errorMessage.text(lang.rateLimited);
|
||||||
|
state.isRateLimited = true;
|
||||||
|
const retryAfter = response.headers.get("Retry-After") || 30;
|
||||||
|
setTimeout(() => {
|
||||||
|
state.lastUsername = "";
|
||||||
|
state.lastPassword = "";
|
||||||
|
state.isRateLimited = false;
|
||||||
|
|
||||||
|
enableLoginButtonIfCriteriaMet();
|
||||||
|
}, retryAfter * 1000);
|
||||||
|
} else {
|
||||||
|
errorMessage.text(lang.unknownError);
|
||||||
|
passwordInput.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loginButton.disable().on("click", handleLoginAttempt);
|
||||||
@ -0,0 +1,442 @@
|
|||||||
|
import { elem, fragment } from "./templating.js";
|
||||||
|
import { animateReposition } from "./animations.js";
|
||||||
|
import { clamp, Vec2, toggleableEvents, throttledDebounce } from "./utils.js";
|
||||||
|
|
||||||
|
const trashIconSvg = `<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.15A1.5 1.5 0 0 0 5.357 15h5.285a1.5 1.5 0 0 0 1.493-1.35l.815-8.15h.3a.75.75 0 0 0 0-1.5H11v-.75A2.25 2.25 0 0 0 8.75 1h-1.5A2.25 2.25 0 0 0 5 3.25Zm2.25-.75a.75.75 0 0 0-.75.75V4h3v-.75a.75.75 0 0 0-.75-.75h-1.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .786-.711Z" clip-rule="evenodd" />
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
export default function(element) {
|
||||||
|
element.swapWith(
|
||||||
|
Todo(element.dataset.todoId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemAnim(height, entrance = true) {
|
||||||
|
const visible = { height: height + "px", opacity: 1 };
|
||||||
|
const hidden = { height: "0", opacity: 0, padding: "0" };
|
||||||
|
|
||||||
|
return {
|
||||||
|
keyframes: [
|
||||||
|
entrance ? hidden : visible,
|
||||||
|
entrance ? visible : hidden
|
||||||
|
],
|
||||||
|
options: { duration: 200, easing: "ease" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputMarginAnim(entrance = true) {
|
||||||
|
const amount = "1.5rem";
|
||||||
|
|
||||||
|
return {
|
||||||
|
keyframes: [
|
||||||
|
{ marginBottom: entrance ? "0px" : amount },
|
||||||
|
{ marginBottom: entrance ? amount : "0" }
|
||||||
|
],
|
||||||
|
options: { duration: 200, easing: "ease", fill: "forwards" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFromLocalStorage(id) {
|
||||||
|
return JSON.parse(localStorage.getItem(`todo-${id}`) || "[]");
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveToLocalStorage(id, data) {
|
||||||
|
localStorage.setItem(`todo-${id}`, JSON.stringify(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
function Item(unserialize = {}, onUpdate, onDelete, onEscape, onDragStart) {
|
||||||
|
let item, input, inputArea;
|
||||||
|
|
||||||
|
const serializeable = {
|
||||||
|
text: unserialize.text || "",
|
||||||
|
checked: unserialize.checked || false
|
||||||
|
};
|
||||||
|
|
||||||
|
item = elem().classes("todo-item", "flex", "gap-10", "items-center").append(
|
||||||
|
elem("input")
|
||||||
|
.classes("todo-item-checkbox", "shrink-0")
|
||||||
|
.styles({ marginTop: "-0.1rem" })
|
||||||
|
.attrs({ type: "checkbox" })
|
||||||
|
.on("change", (e) => {
|
||||||
|
serializeable.checked = e.target.checked;
|
||||||
|
onUpdate();
|
||||||
|
})
|
||||||
|
.tap(self => self.checked = serializeable.checked),
|
||||||
|
|
||||||
|
input = autoScalingTextarea(textarea => inputArea = textarea
|
||||||
|
.classes("todo-item-text")
|
||||||
|
.attrs({
|
||||||
|
placeholder: "empty task",
|
||||||
|
spellcheck: "false"
|
||||||
|
})
|
||||||
|
.on("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
onEscape();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on("input", () => {
|
||||||
|
serializeable.text = inputArea.value;
|
||||||
|
onUpdate();
|
||||||
|
})
|
||||||
|
).classes("min-width-0", "grow").append(
|
||||||
|
elem()
|
||||||
|
.classes("todo-item-drag-handle")
|
||||||
|
.on("mousedown", (e) => onDragStart(e, item))
|
||||||
|
),
|
||||||
|
|
||||||
|
elem("button")
|
||||||
|
.classes("todo-item-delete", "shrink-0")
|
||||||
|
.html(trashIconSvg)
|
||||||
|
.on("click", () => onDelete(item))
|
||||||
|
);
|
||||||
|
|
||||||
|
input.component.setValue(serializeable.text);
|
||||||
|
return item.component({
|
||||||
|
focusInput: () => inputArea.focus(),
|
||||||
|
serialize: () => serializeable
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function Todo(id) {
|
||||||
|
let items, input, inputArea, inputContainer, lastAddedItem;
|
||||||
|
let queuedForRemoval = 0;
|
||||||
|
let reorderable;
|
||||||
|
let isDragging = false;
|
||||||
|
|
||||||
|
const onDragEnd = () => isDragging = false;
|
||||||
|
const onDragStart = (event, element) => {
|
||||||
|
isDragging = true;
|
||||||
|
reorderable.component.onDragStart(event, element);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveItems = () => {
|
||||||
|
if (isDragging) return;
|
||||||
|
|
||||||
|
saveToLocalStorage(
|
||||||
|
id, items.children.map(item => item.component.serialize())
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onItemRepositioned = () => saveItems();
|
||||||
|
const debouncedOnItemUpdate = throttledDebounce(saveItems, 10, 1000);
|
||||||
|
|
||||||
|
const onItemDelete = (item) => {
|
||||||
|
if (lastAddedItem === item) lastAddedItem = null;
|
||||||
|
const height = item.clientHeight;
|
||||||
|
queuedForRemoval++;
|
||||||
|
item.animate(itemAnim(height, false), () => {
|
||||||
|
item.remove();
|
||||||
|
queuedForRemoval--;
|
||||||
|
saveItems();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (items.children.length - queuedForRemoval === 0)
|
||||||
|
inputContainer.animate(inputMarginAnim(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const newItem = (data) => Item(
|
||||||
|
data,
|
||||||
|
debouncedOnItemUpdate,
|
||||||
|
onItemDelete,
|
||||||
|
() => inputArea.focus(),
|
||||||
|
onDragStart
|
||||||
|
);
|
||||||
|
|
||||||
|
const addNewItem = (itemText, prepend) => {
|
||||||
|
const totalItemsBeforeAppending = items.children.length;
|
||||||
|
const item = lastAddedItem = newItem({ text: itemText });
|
||||||
|
|
||||||
|
prepend ? items.prepend(item) : items.append(item);
|
||||||
|
saveItems();
|
||||||
|
const height = item.clientHeight;
|
||||||
|
item.animate(itemAnim(height));
|
||||||
|
|
||||||
|
if (totalItemsBeforeAppending === 0)
|
||||||
|
inputContainer.animate(inputMarginAnim());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputKeyDown = (e) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case "Enter":
|
||||||
|
e.preventDefault();
|
||||||
|
const value = e.target.value.trim();
|
||||||
|
if (value === "") return;
|
||||||
|
addNewItem(value, e.ctrlKey);
|
||||||
|
input.component.setValue("");
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
e.target.blur();
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
if (!lastAddedItem) return;
|
||||||
|
e.preventDefault();
|
||||||
|
lastAddedItem.component.focusInput();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
items = elem()
|
||||||
|
.classes("todo-items")
|
||||||
|
.append(
|
||||||
|
...loadFromLocalStorage(id).map(data => newItem(data))
|
||||||
|
);
|
||||||
|
|
||||||
|
return fragment().append(
|
||||||
|
inputContainer = elem()
|
||||||
|
.classes("todo-input", "flex", "gap-10", "items-center")
|
||||||
|
.classesIf(items.children.length > 0, "margin-bottom-15")
|
||||||
|
.styles({ paddingRight: "2.5rem" })
|
||||||
|
.append(
|
||||||
|
elem().classes("todo-plus-icon", "shrink-0"),
|
||||||
|
input = autoScalingTextarea(textarea => inputArea = textarea
|
||||||
|
.on("keydown", handleInputKeyDown)
|
||||||
|
.attrs({
|
||||||
|
placeholder: "Add a task",
|
||||||
|
spellcheck: "false"
|
||||||
|
})
|
||||||
|
).classes("grow", "min-width-0")
|
||||||
|
),
|
||||||
|
|
||||||
|
reorderable = verticallyReorderable(items, onItemRepositioned, onDragEnd),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// See https://css-tricks.com/the-cleanest-trick-for-autogrowing-textareas/
|
||||||
|
export function autoScalingTextarea(yieldTextarea = null) {
|
||||||
|
let textarea, mimic;
|
||||||
|
|
||||||
|
const updateMimic = (newValue) => mimic.text(newValue + ' ');
|
||||||
|
const container = elem().classes("auto-scaling-textarea-container").append(
|
||||||
|
textarea = elem("textarea")
|
||||||
|
.classes("auto-scaling-textarea")
|
||||||
|
.on("input", () => updateMimic(textarea.value)),
|
||||||
|
mimic = elem().classes("auto-scaling-textarea-mimic")
|
||||||
|
)
|
||||||
|
|
||||||
|
if (typeof yieldTextarea === "function") yieldTextarea(textarea);
|
||||||
|
|
||||||
|
return container.component({ setValue: (newValue) => {
|
||||||
|
textarea.value = newValue;
|
||||||
|
updateMimic(newValue);
|
||||||
|
}});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verticallyReorderable(itemsContainer, onItemRepositioned, onDragEnd) {
|
||||||
|
const classToAddToDraggedItem = "is-being-dragged";
|
||||||
|
|
||||||
|
const currentlyBeingDragged = {
|
||||||
|
element: null,
|
||||||
|
initialIndex: null,
|
||||||
|
clientOffset: Vec2.new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const decoy = {
|
||||||
|
element: null,
|
||||||
|
currentIndex: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const draggableContainer = {
|
||||||
|
element: null,
|
||||||
|
initialRect: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const lastClientPos = Vec2.new();
|
||||||
|
let initialScrollY = null;
|
||||||
|
let addDocumentEvents, removeDocumentEvents;
|
||||||
|
|
||||||
|
const handleReposition = (event) => {
|
||||||
|
if (currentlyBeingDragged.element == null) return;
|
||||||
|
|
||||||
|
if (event.clientY !== undefined && event.clientX !== undefined)
|
||||||
|
lastClientPos.setFromEvent(event);
|
||||||
|
|
||||||
|
const client = lastClientPos;
|
||||||
|
const container = draggableContainer;
|
||||||
|
const item = currentlyBeingDragged;
|
||||||
|
|
||||||
|
const scrollOffset = window.scrollY - initialScrollY;
|
||||||
|
const offsetY = client.y - container.initialRect.y - item.clientOffset.y + scrollOffset;
|
||||||
|
const offsetX = client.x - container.initialRect.x - item.clientOffset.x;
|
||||||
|
|
||||||
|
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
||||||
|
const viewportWidth = window.innerWidth - scrollbarWidth;
|
||||||
|
|
||||||
|
const confinedX = clamp(
|
||||||
|
offsetX,
|
||||||
|
-container.initialRect.x,
|
||||||
|
viewportWidth - container.initialRect.x - container.initialRect.width
|
||||||
|
);
|
||||||
|
|
||||||
|
container.element.styles({
|
||||||
|
transform: `translate(${confinedX}px, ${offsetY}px)`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const containerTop = client.y - item.clientOffset.y;
|
||||||
|
const containerBottom = client.y + container.initialRect.height - item.clientOffset.y;
|
||||||
|
|
||||||
|
let swapWithLast = true;
|
||||||
|
let swapWithIndex = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < itemsContainer.children.length; i++) {
|
||||||
|
const childRect = itemsContainer.children[i].getBoundingClientRect();
|
||||||
|
const topThreshold = childRect.top + childRect.height * .6;
|
||||||
|
const bottomThreshold = childRect.top + childRect.height * .4;
|
||||||
|
|
||||||
|
if (containerBottom > topThreshold) {
|
||||||
|
if (containerTop < bottomThreshold && i != decoy.currentIndex) {
|
||||||
|
swapWithIndex = i;
|
||||||
|
swapWithLast = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
swapWithLast = false;
|
||||||
|
|
||||||
|
if (i == decoy.currentIndex || i-1 == decoy.currentIndex) break;
|
||||||
|
swapWithIndex = (i < decoy.currentIndex) ? i : i-1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastItemIndex = itemsContainer.children.length - 1;
|
||||||
|
|
||||||
|
if (swapWithLast && decoy.currentIndex != lastItemIndex)
|
||||||
|
swapWithIndex = lastItemIndex;
|
||||||
|
|
||||||
|
if (swapWithIndex === null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const diff = swapWithIndex - decoy.currentIndex;
|
||||||
|
if (Math.abs(diff) > 1) {
|
||||||
|
swapWithIndex = decoy.currentIndex + Math.sign(diff);
|
||||||
|
}
|
||||||
|
|
||||||
|
const siblingToSwapWith = itemsContainer.children[swapWithIndex];
|
||||||
|
|
||||||
|
if (siblingToSwapWith.isCurrentlyAnimating) return;
|
||||||
|
|
||||||
|
const animateDecoy = animateReposition(decoy.element);
|
||||||
|
const animateChild = animateReposition(
|
||||||
|
siblingToSwapWith,
|
||||||
|
() => {
|
||||||
|
siblingToSwapWith.isCurrentlyAnimating = false;
|
||||||
|
handleReposition({
|
||||||
|
clientX: client.x,
|
||||||
|
clientY: client.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
siblingToSwapWith.isCurrentlyAnimating = true;
|
||||||
|
|
||||||
|
if (swapWithIndex > decoy.currentIndex)
|
||||||
|
decoy.element.before(siblingToSwapWith);
|
||||||
|
else
|
||||||
|
decoy.element.after(siblingToSwapWith);
|
||||||
|
|
||||||
|
decoy.currentIndex = itemsContainer.children.indexOf(decoy.element);
|
||||||
|
|
||||||
|
animateDecoy();
|
||||||
|
animateChild();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRelease = (event) => {
|
||||||
|
if (event.buttons != 0) return;
|
||||||
|
|
||||||
|
removeDocumentEvents();
|
||||||
|
const item = currentlyBeingDragged;
|
||||||
|
const element = item.element;
|
||||||
|
element.styles({ pointerEvents: "none" });
|
||||||
|
const animate = animateReposition(element, () => {
|
||||||
|
item.element = null;
|
||||||
|
element
|
||||||
|
.clearClasses(classToAddToDraggedItem)
|
||||||
|
.clearStyles("pointer-events");
|
||||||
|
|
||||||
|
if (typeof onDragEnd === "function") onDragEnd(element);
|
||||||
|
|
||||||
|
if (item.initialIndex != decoy.currentIndex && typeof onItemRepositioned === "function")
|
||||||
|
onItemRepositioned(element, item.initialIndex, decoy.currentIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
decoy.element.swapWith(element);
|
||||||
|
draggableContainer.element.append(decoy.element);
|
||||||
|
draggableContainer.element.clearStyles("transform", "width");
|
||||||
|
|
||||||
|
item.element = null;
|
||||||
|
decoy.element.remove();
|
||||||
|
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
const preventDefault = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGrab = (event, element) => {
|
||||||
|
if (currentlyBeingDragged.element != null) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const item = currentlyBeingDragged;
|
||||||
|
if (item.element != null) return;
|
||||||
|
|
||||||
|
addDocumentEvents();
|
||||||
|
initialScrollY = window.scrollY;
|
||||||
|
const client = lastClientPos.setFromEvent(event);
|
||||||
|
const elementRect = element.getBoundingClientRect();
|
||||||
|
|
||||||
|
item.element = element;
|
||||||
|
item.initialIndex = decoy.currentIndex = itemsContainer.children.indexOf(element);
|
||||||
|
item.clientOffset.set(client.x - elementRect.x, client.y - elementRect.y);
|
||||||
|
|
||||||
|
// We use getComputedStyle here to get width and height because .clientWidth and .clientHeight
|
||||||
|
// return integers and not the real float values, which can cause the decoy to be off by a pixel
|
||||||
|
const elementStyle = getComputedStyle(element);
|
||||||
|
const initialWidth = elementStyle.width;
|
||||||
|
|
||||||
|
decoy.element = elem().classes("drag-and-drop-decoy").styles({
|
||||||
|
height: elementStyle.height,
|
||||||
|
width: initialWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
const container = draggableContainer;
|
||||||
|
|
||||||
|
element.swapWith(decoy.element);
|
||||||
|
container.element.append(element);
|
||||||
|
element.classes(classToAddToDraggedItem);
|
||||||
|
|
||||||
|
decoy.element.animate({
|
||||||
|
keyframes: [{ transform: "scale(.9)", opacity: 0, offset: 0 }],
|
||||||
|
options: { duration: 300, easing: "ease" }
|
||||||
|
})
|
||||||
|
|
||||||
|
container.element.styles({ width: initialWidth, transform: "none" });
|
||||||
|
container.initialRect = container.element.getBoundingClientRect();
|
||||||
|
|
||||||
|
const offsetY = elementRect.y - container.initialRect.y;
|
||||||
|
const offsetX = elementRect.x - container.initialRect.x;
|
||||||
|
|
||||||
|
container.element.styles({ transform: `translate(${offsetX}px, ${offsetY}px)` });
|
||||||
|
}
|
||||||
|
|
||||||
|
[addDocumentEvents, removeDocumentEvents] = toggleableEvents(document, {
|
||||||
|
"mousemove": handleReposition,
|
||||||
|
"scroll": handleReposition,
|
||||||
|
"mousedown": preventDefault,
|
||||||
|
"contextmenu": preventDefault,
|
||||||
|
"mouseup": handleRelease,
|
||||||
|
});
|
||||||
|
|
||||||
|
return elem().classes("drag-and-drop-container").append(
|
||||||
|
itemsContainer,
|
||||||
|
draggableContainer.element = elem().classes("drag-and-drop-draggable")
|
||||||
|
).component({
|
||||||
|
onDragStart: handleGrab
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Glance",
|
|
||||||
"display": "standalone",
|
|
||||||
"background_color": "#151519",
|
|
||||||
"scope": "/",
|
|
||||||
"start_url": "/",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "app-icon.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "512x512"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<ul class="bookmarks-grid">
|
||||||
|
{{- range .Links }}
|
||||||
|
<li class="icon-parent text-center padding-inline-10">
|
||||||
|
<a href="{{ .URL | safeURL }}" class="block" {{ if .Target }}target="{{ .Target }}"{{ end }} rel="noreferrer">
|
||||||
|
{{- if ne "" .Icon.URL }}
|
||||||
|
<div class="padding-10 rounded bg-highlight width-max-content margin-inline-auto">
|
||||||
|
{{ .Icon.ElemWithClass "square-40" }}
|
||||||
|
</div>
|
||||||
|
{{- end }}
|
||||||
|
<p class="margin-top-7 block text-truncate color-highlight size-h4">{{ .Title }}</p>
|
||||||
|
</a>
|
||||||
|
{{- if .Description }}
|
||||||
|
<div class="margin-top-5 text-truncate-2-lines text-compact">{{ .Description }}</div>
|
||||||
|
{{- end }}
|
||||||
|
</li>
|
||||||
|
{{- end }}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
{{ if not .App.Config.Branding.HideFooter }}
|
||||||
|
<footer class="footer flex items-center flex-column">
|
||||||
|
{{ if eq "" .App.Config.Branding.CustomFooter }}
|
||||||
|
<div>
|
||||||
|
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
{{ .App.Config.Branding.CustomFooter }}
|
||||||
|
{{ end }}
|
||||||
|
</footer>
|
||||||
|
{{ end }}
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
{{- template "document.html" . }}
|
||||||
|
|
||||||
|
{{- define "document-title" }}Login{{ end }}
|
||||||
|
|
||||||
|
{{- define "document-head-before" }}
|
||||||
|
<link rel="preload" href='{{ .App.StaticAssetPath "js/templating.js" }}' as="script"/>
|
||||||
|
<link rel="prefetch" href='{{ .App.StaticAssetPath "js/page.js" }}'/>
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "document-head-after" }}
|
||||||
|
<link rel="stylesheet" href='{{ .App.StaticAssetPath "css/login.css" }}'>
|
||||||
|
<script type="module" src='{{ .App.StaticAssetPath "js/login.js" }}'></script>
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "document-body" }}
|
||||||
|
<div class="flex flex-column body-content">
|
||||||
|
<div class="flex grow items-center justify-center" style="padding-bottom: 5rem">
|
||||||
|
<h1 class="visually-hidden">Login</h1>
|
||||||
|
<main id="login-container" class="grow login-bounds" style="display: none;">
|
||||||
|
<div class="animate-entrance">
|
||||||
|
<label class="form-label widget-header" for="username">Username</label>
|
||||||
|
<div class="form-input widget-content-frame padding-inline-widget flex gap-10 items-center">
|
||||||
|
<svg class="form-input-icon" fill="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path d="M10 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM3.465 14.493a1.23 1.23 0 0 0 .41 1.412A9.957 9.957 0 0 0 10 18c2.31 0 4.438-.784 6.131-2.1.43-.333.604-.903.408-1.41a7.002 7.002 0 0 0-13.074.003Z" />
|
||||||
|
</svg>
|
||||||
|
<input type="text" id="username" class="input" placeholder="Enter your username" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="animate-entrance">
|
||||||
|
<label class="form-label widget-header margin-top-20" for="password">Password</label>
|
||||||
|
<div class="form-input widget-content-frame padding-inline-widget flex gap-10 items-center">
|
||||||
|
<svg class="form-input-icon" fill="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M8 7a5 5 0 1 1 3.61 4.804l-1.903 1.903A1 1 0 0 1 9 14H8v1a1 1 0 0 1-1 1H6v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-2a1 1 0 0 1 .293-.707L8.196 8.39A5.002 5.002 0 0 1 8 7Zm5-3a.75.75 0 0 0 0 1.5A1.5 1.5 0 0 1 14.5 7 .75.75 0 0 0 16 7a3 3 0 0 0-3-3Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<input type="password" id="password" class="input" placeholder="********" autocomplete="off">
|
||||||
|
<button class="toggle-password-visibility" id="toggle-password-visibility" tabindex="-1"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="login-error-message" id="error-message"></div>
|
||||||
|
|
||||||
|
<button class="login-button animate-entrance" id="login-button">
|
||||||
|
<div>LOGIN</div>
|
||||||
|
<svg stroke="currentColor" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{{ template "footer.html" . }}
|
||||||
|
</div>
|
||||||
|
{{- end }}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "{{ .App.Config.Branding.AppName }}",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "{{ .App.Config.Branding.AppBackgroundColor }}",
|
||||||
|
"theme_color": "{{ .App.Config.Branding.AppBackgroundColor }}",
|
||||||
|
"scope": "/",
|
||||||
|
"start_url": "/",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "{{ .App.Config.Branding.AppIconURL }}",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
{{- $background := "hsl(240, 8%, 9%)" | safeCSS }}
|
||||||
|
{{- $primary := "hsl(43, 50%, 70%)" | safeCSS }}
|
||||||
|
{{- $positive := "hsl(43, 50%, 70%)" | safeCSS }}
|
||||||
|
{{- $negative := "hsl(0, 70%, 70%)" | safeCSS }}
|
||||||
|
{{- if .BackgroundColor }}{{ $background = .BackgroundColor.String | safeCSS }}{{ end }}
|
||||||
|
{{- if .PrimaryColor }}
|
||||||
|
{{- $primary = .PrimaryColor.String | safeCSS }}
|
||||||
|
{{- if not .PositiveColor }}
|
||||||
|
{{- $positive = $primary }}
|
||||||
|
{{- else }}
|
||||||
|
{{- $positive = .PositiveColor.String | safeCSS }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .NegativeColor }}{{ $negative = .NegativeColor.String | safeCSS }}{{ end }}
|
||||||
|
<button class="theme-preset{{ if .Light }} theme-preset-light{{ end }}" style="--color: {{ $background }}" data-key="{{ .Key }}" title="{{ .Key }}">
|
||||||
|
<div class="theme-color" style="--color: {{ $primary }}"></div>
|
||||||
|
<div class="theme-color" style="--color: {{ $positive }}"></div>
|
||||||
|
<div class="theme-color" style="--color: {{ $negative }}"></div>
|
||||||
|
</button>
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<div class="todo" data-todo-id="{{ .TodoID }}"></div>
|
||||||
|
{{ end }}
|
||||||
@ -0,0 +1,107 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
themeStyleTemplate = mustParseTemplate("theme-style.gotmpl")
|
||||||
|
themePresetPreviewTemplate = mustParseTemplate("theme-preset-preview.html")
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *application) handleThemeChangeRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
themeKey := r.PathValue("key")
|
||||||
|
|
||||||
|
properties, exists := a.Config.Theme.Presets.Get(themeKey)
|
||||||
|
if !exists && themeKey != "default" {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if themeKey == "default" {
|
||||||
|
properties = &a.Config.Theme.themeProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "theme",
|
||||||
|
Value: themeKey,
|
||||||
|
Path: a.Config.Server.BaseURL + "/",
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Expires: time.Now().Add(2 * 365 * 24 * time.Hour),
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/css")
|
||||||
|
w.Header().Set("X-Scheme", ternary(properties.Light, "light", "dark"))
|
||||||
|
w.Write([]byte(properties.CSS))
|
||||||
|
}
|
||||||
|
|
||||||
|
type themeProperties struct {
|
||||||
|
BackgroundColor *hslColorField `yaml:"background-color"`
|
||||||
|
PrimaryColor *hslColorField `yaml:"primary-color"`
|
||||||
|
PositiveColor *hslColorField `yaml:"positive-color"`
|
||||||
|
NegativeColor *hslColorField `yaml:"negative-color"`
|
||||||
|
Light bool `yaml:"light"`
|
||||||
|
ContrastMultiplier float32 `yaml:"contrast-multiplier"`
|
||||||
|
TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"`
|
||||||
|
|
||||||
|
Key string `yaml:"-"`
|
||||||
|
CSS template.CSS `yaml:"-"`
|
||||||
|
PreviewHTML template.HTML `yaml:"-"`
|
||||||
|
BackgroundColorAsHex string `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *themeProperties) init() error {
|
||||||
|
css, err := executeTemplateToString(themeStyleTemplate, t)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("compiling theme style: %v", err)
|
||||||
|
}
|
||||||
|
t.CSS = template.CSS(whitespaceAtBeginningOfLinePattern.ReplaceAllString(css, ""))
|
||||||
|
|
||||||
|
previewHTML, err := executeTemplateToString(themePresetPreviewTemplate, t)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("compiling theme preview: %v", err)
|
||||||
|
}
|
||||||
|
t.PreviewHTML = template.HTML(previewHTML)
|
||||||
|
|
||||||
|
if t.BackgroundColor != nil {
|
||||||
|
t.BackgroundColorAsHex = t.BackgroundColor.ToHex()
|
||||||
|
} else {
|
||||||
|
t.BackgroundColorAsHex = "#151519"
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t1 *themeProperties) SameAs(t2 *themeProperties) bool {
|
||||||
|
if t1 == nil && t2 == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if t1 == nil || t2 == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if t1.Light != t2.Light {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if t1.ContrastMultiplier != t2.ContrastMultiplier {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if t1.TextSaturationMultiplier != t2.TextSaturationMultiplier {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !t1.BackgroundColor.SameAs(t2.BackgroundColor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !t1.PrimaryColor.SameAs(t2.PrimaryColor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !t1.PositiveColor.SameAs(t2.PositiveColor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !t1.NegativeColor.SameAs(t2.NegativeColor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
var todoWidgetTemplate = mustParseTemplate("todo.html", "widget-base.html")
|
||||||
|
|
||||||
|
type todoWidget struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
cachedHTML template.HTML `yaml:"-"`
|
||||||
|
TodoID string `yaml:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *todoWidget) initialize() error {
|
||||||
|
widget.withTitle("To-do").withError(nil)
|
||||||
|
|
||||||
|
widget.cachedHTML = widget.renderTemplate(widget, todoWidgetTemplate)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *todoWidget) Render() template.HTML {
|
||||||
|
return widget.cachedHTML
|
||||||
|
}
|
||||||