migrated out more modules and refactored db system
This commit is contained in:
@@ -5,12 +5,10 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"projectreshoot/pkg/logging"
|
||||
"projectreshoot/pkg/tmdb"
|
||||
|
||||
"git.haelnorr.com/h/golib/hlog"
|
||||
"git.haelnorr.com/h/golib/tmdb"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -28,7 +26,7 @@ type Config struct {
|
||||
AccessTokenExpiry int64 // Access token expiry in minutes
|
||||
RefreshTokenExpiry int64 // Refresh token expiry in minutes
|
||||
TokenFreshTime int64 // Time for tokens to stay fresh in minutes
|
||||
LogLevel zerolog.Level // Log level for global logging. Defaults to info
|
||||
LogLevel hlog.Level // Log level for global logging. Defaults to info
|
||||
LogOutput string // "file", "console", or "both". Defaults to console
|
||||
LogDir string // Path to create log files
|
||||
TMDBToken string // Read access token for TMDB API
|
||||
@@ -41,7 +39,7 @@ func GetConfig(args map[string]string) (*Config, error) {
|
||||
var (
|
||||
host string
|
||||
port string
|
||||
logLevel zerolog.Level
|
||||
logLevel hlog.Level
|
||||
logOutput string
|
||||
valid bool
|
||||
)
|
||||
@@ -57,9 +55,9 @@ func GetConfig(args map[string]string) (*Config, error) {
|
||||
port = GetEnvDefault("PORT", "3010")
|
||||
}
|
||||
if args["loglevel"] != "" {
|
||||
logLevel = logging.GetLogLevel(args["loglevel"])
|
||||
logLevel = hlog.LogLevel(args["loglevel"])
|
||||
} else {
|
||||
logLevel = logging.GetLogLevel(GetEnvDefault("LOG_LEVEL", "info"))
|
||||
logLevel = hlog.LogLevel(GetEnvDefault("LOG_LEVEL", "info"))
|
||||
}
|
||||
if args["logoutput"] != "" {
|
||||
opts := map[string]string{
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
|
||||
"projectreshoot/internal/models"
|
||||
"projectreshoot/pkg/config"
|
||||
"projectreshoot/pkg/jwt"
|
||||
|
||||
"git.haelnorr.com/h/golib/jwt"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
@@ -58,15 +58,16 @@ func SetTokenCookies(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
config *config.Config,
|
||||
tokenGen *jwt.TokenGenerator,
|
||||
user *models.User,
|
||||
fresh bool,
|
||||
rememberMe bool,
|
||||
) error {
|
||||
at, atexp, err := jwt.GenerateAccessToken(config, user, fresh, rememberMe)
|
||||
at, atexp, err := tokenGen.NewAccess(user.ID, fresh, rememberMe)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "jwt.GenerateAccessToken")
|
||||
}
|
||||
rt, rtexp, err := jwt.GenerateRefreshToken(config, user, rememberMe)
|
||||
rt, rtexp, err := tokenGen.NewRefresh(user.ID, rememberMe)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "jwt.GenerateRefreshToken")
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// Returns a database connection handle for the DB
|
||||
func ConnectToDatabase(
|
||||
dbName string,
|
||||
logger *zerolog.Logger,
|
||||
) (*SafeConn, error) {
|
||||
opts := "_journal_mode=WAL&_synchronous=NORMAL&_txlock=IMMEDIATE"
|
||||
file := fmt.Sprintf("file:%s.db?%s", dbName, opts)
|
||||
wconn, err := sql.Open("sqlite3", file)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "sql.Open (rw)")
|
||||
}
|
||||
wconn.SetMaxOpenConns(1)
|
||||
opts = "_synchronous=NORMAL&mode=ro"
|
||||
file = fmt.Sprintf("file:%s.db?%s", dbName, opts)
|
||||
|
||||
rconn, err := sql.Open("sqlite3", file)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "sql.Open (ro)")
|
||||
}
|
||||
|
||||
version, err := strconv.Atoi(dbName)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "strconv.Atoi")
|
||||
}
|
||||
err = checkDBVersion(rconn, version)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "checkDBVersion")
|
||||
}
|
||||
conn := MakeSafe(wconn, rconn, logger)
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// Check the database version
|
||||
func checkDBVersion(db *sql.DB, expectVer int) error {
|
||||
query := `SELECT version_id FROM goose_db_version WHERE is_applied = 1
|
||||
ORDER BY version_id DESC LIMIT 1`
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "db.Query")
|
||||
}
|
||||
defer rows.Close()
|
||||
if rows.Next() {
|
||||
var version int
|
||||
err = rows.Scan(&version)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "rows.Scan")
|
||||
}
|
||||
if version != expectVer {
|
||||
return errors.New("Version mismatch")
|
||||
}
|
||||
} else {
|
||||
return errors.New("No version found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type SafeConn struct {
|
||||
wconn *sql.DB
|
||||
rconn *sql.DB
|
||||
readLockCount uint32
|
||||
globalLockStatus uint32
|
||||
globalLockRequested uint32
|
||||
logger *zerolog.Logger
|
||||
}
|
||||
|
||||
// Make the provided db handle safe and attach a logger to it
|
||||
func MakeSafe(wconn *sql.DB, rconn *sql.DB, logger *zerolog.Logger) *SafeConn {
|
||||
return &SafeConn{wconn: wconn, rconn: rconn, logger: logger}
|
||||
}
|
||||
|
||||
// Attempts to acquire a global lock on the database connection
|
||||
func (conn *SafeConn) acquireGlobalLock() bool {
|
||||
if conn.readLockCount > 0 || conn.globalLockStatus == 1 {
|
||||
return false
|
||||
}
|
||||
conn.globalLockStatus = 1
|
||||
conn.logger.Debug().Uint32("global_lock_status", conn.globalLockStatus).
|
||||
Msg("Global lock acquired")
|
||||
return true
|
||||
}
|
||||
|
||||
// Releases a global lock on the database connection
|
||||
func (conn *SafeConn) releaseGlobalLock() {
|
||||
conn.globalLockStatus = 0
|
||||
conn.logger.Debug().Uint32("global_lock_status", conn.globalLockStatus).
|
||||
Msg("Global lock released")
|
||||
}
|
||||
|
||||
// Acquire a read lock on the connection. Multiple read locks can be acquired
|
||||
// at the same time
|
||||
func (conn *SafeConn) acquireReadLock() bool {
|
||||
if conn.globalLockStatus == 1 || conn.globalLockRequested == 1 {
|
||||
return false
|
||||
}
|
||||
conn.readLockCount += 1
|
||||
conn.logger.Debug().Uint32("read_lock_count", conn.readLockCount).
|
||||
Msg("Read lock acquired")
|
||||
return true
|
||||
}
|
||||
|
||||
// Release a read lock. Decrements read lock count by 1
|
||||
func (conn *SafeConn) releaseReadLock() {
|
||||
conn.readLockCount -= 1
|
||||
conn.logger.Debug().Uint32("read_lock_count", conn.readLockCount).
|
||||
Msg("Read lock released")
|
||||
}
|
||||
|
||||
// Starts a new transaction based on the current context. Will cancel if
|
||||
// the context is closed/cancelled/done
|
||||
func (conn *SafeConn) Begin(ctx context.Context) (*SafeWTX, error) {
|
||||
lockAcquired := make(chan struct{})
|
||||
lockCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
go func() {
|
||||
select {
|
||||
case <-lockCtx.Done():
|
||||
return
|
||||
default:
|
||||
if conn.acquireReadLock() {
|
||||
close(lockAcquired)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-lockAcquired:
|
||||
tx, err := conn.wconn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
conn.releaseReadLock()
|
||||
return nil, err
|
||||
}
|
||||
return &SafeWTX{tx: tx, sc: conn}, nil
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
return nil, errors.New("Transaction time out due to database lock")
|
||||
}
|
||||
}
|
||||
|
||||
// Starts a new READONLY transaction based on the current context. Will cancel if
|
||||
// the context is closed/cancelled/done
|
||||
func (conn *SafeConn) RBegin(ctx context.Context) (*SafeRTX, error) {
|
||||
lockAcquired := make(chan struct{})
|
||||
lockCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
go func() {
|
||||
select {
|
||||
case <-lockCtx.Done():
|
||||
return
|
||||
default:
|
||||
if conn.acquireReadLock() {
|
||||
close(lockAcquired)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-lockAcquired:
|
||||
tx, err := conn.rconn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
conn.releaseReadLock()
|
||||
return nil, err
|
||||
}
|
||||
return &SafeRTX{tx: tx, sc: conn}, nil
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
return nil, errors.New("Transaction time out due to database lock")
|
||||
}
|
||||
}
|
||||
|
||||
// Acquire a global lock, preventing all transactions
|
||||
func (conn *SafeConn) Pause(timeoutAfter time.Duration) {
|
||||
conn.logger.Info().Msg("Attempting to acquire global database lock")
|
||||
conn.globalLockRequested = 1
|
||||
defer func() { conn.globalLockRequested = 0 }()
|
||||
timeout := time.After(timeoutAfter)
|
||||
attempt := 0
|
||||
for {
|
||||
if conn.acquireGlobalLock() {
|
||||
conn.logger.Info().Msg("Global database lock acquired")
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-timeout:
|
||||
conn.logger.Info().Msg("Timeout: Global database lock abandoned")
|
||||
return
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
attempt++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Release the global lock
|
||||
func (conn *SafeConn) Resume() {
|
||||
conn.releaseGlobalLock()
|
||||
conn.logger.Info().Msg("Global database lock released")
|
||||
}
|
||||
|
||||
// Close the database connection
|
||||
func (conn *SafeConn) Close() error {
|
||||
conn.logger.Debug().Msg("Acquiring global lock for connection close")
|
||||
conn.acquireGlobalLock()
|
||||
defer conn.releaseGlobalLock()
|
||||
conn.logger.Debug().Msg("Closing database connection")
|
||||
return conn.wconn.Close()
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"projectreshoot/pkg/tests"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSafeConn(t *testing.T) {
|
||||
cfg, err := tests.TestConfig()
|
||||
require.NoError(t, err)
|
||||
logger := tests.NilLogger()
|
||||
ver, err := strconv.ParseInt(cfg.DBName, 10, 0)
|
||||
require.NoError(t, err)
|
||||
wconn, rconn, err := tests.SetupTestDB(ver)
|
||||
require.NoError(t, err)
|
||||
sconn := MakeSafe(wconn, rconn, logger)
|
||||
defer sconn.Close()
|
||||
|
||||
t.Run("Global lock waits for read locks to finish", func(t *testing.T) {
|
||||
tx, err := sconn.Begin(t.Context())
|
||||
require.NoError(t, err)
|
||||
var requested sync.WaitGroup
|
||||
var engaged sync.WaitGroup
|
||||
requested.Add(1)
|
||||
engaged.Add(1)
|
||||
go func() {
|
||||
requested.Done()
|
||||
sconn.Pause(5 * time.Second)
|
||||
engaged.Done()
|
||||
}()
|
||||
requested.Wait()
|
||||
assert.Equal(t, uint32(0), sconn.globalLockStatus)
|
||||
assert.Equal(t, uint32(1), sconn.globalLockRequested)
|
||||
tx.Commit()
|
||||
engaged.Wait()
|
||||
assert.Equal(t, uint32(1), sconn.globalLockStatus)
|
||||
assert.Equal(t, uint32(0), sconn.globalLockRequested)
|
||||
sconn.Resume()
|
||||
})
|
||||
t.Run("Lock abandons after timeout", func(t *testing.T) {
|
||||
tx, err := sconn.Begin(t.Context())
|
||||
require.NoError(t, err)
|
||||
sconn.Pause(250 * time.Millisecond)
|
||||
assert.Equal(t, uint32(0), sconn.globalLockStatus)
|
||||
assert.Equal(t, uint32(0), sconn.globalLockRequested)
|
||||
tx.Commit()
|
||||
})
|
||||
t.Run("Pause blocks transactions and resume allows", func(t *testing.T) {
|
||||
tx, err := sconn.Begin(t.Context())
|
||||
require.NoError(t, err)
|
||||
var requested sync.WaitGroup
|
||||
var engaged sync.WaitGroup
|
||||
requested.Add(1)
|
||||
engaged.Add(1)
|
||||
go func() {
|
||||
requested.Done()
|
||||
sconn.Pause(5 * time.Second)
|
||||
engaged.Done()
|
||||
}()
|
||||
requested.Wait()
|
||||
assert.Equal(t, uint32(0), sconn.globalLockStatus)
|
||||
assert.Equal(t, uint32(1), sconn.globalLockRequested)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 250*time.Millisecond)
|
||||
defer cancel()
|
||||
_, err = sconn.Begin(ctx)
|
||||
require.Error(t, err)
|
||||
tx.Commit()
|
||||
engaged.Wait()
|
||||
_, err = sconn.Begin(ctx)
|
||||
require.Error(t, err)
|
||||
sconn.Resume()
|
||||
tx, err = sconn.Begin(t.Context())
|
||||
require.NoError(t, err)
|
||||
tx.Commit()
|
||||
})
|
||||
}
|
||||
func TestSafeTX(t *testing.T) {
|
||||
cfg, err := tests.TestConfig()
|
||||
require.NoError(t, err)
|
||||
logger := tests.NilLogger()
|
||||
ver, err := strconv.ParseInt(cfg.DBName, 10, 0)
|
||||
require.NoError(t, err)
|
||||
wconn, rconn, err := tests.SetupTestDB(ver)
|
||||
require.NoError(t, err)
|
||||
sconn := MakeSafe(wconn, rconn, logger)
|
||||
defer sconn.Close()
|
||||
|
||||
t.Run("Commit releases lock", func(t *testing.T) {
|
||||
tx, err := sconn.Begin(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uint32(1), sconn.readLockCount)
|
||||
tx.Commit()
|
||||
assert.Equal(t, uint32(0), sconn.readLockCount)
|
||||
})
|
||||
t.Run("Rollback releases lock", func(t *testing.T) {
|
||||
tx, err := sconn.Begin(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uint32(1), sconn.readLockCount)
|
||||
tx.Rollback()
|
||||
assert.Equal(t, uint32(0), sconn.readLockCount)
|
||||
})
|
||||
t.Run("Multiple RTX can gain read lock", func(t *testing.T) {
|
||||
tx1, err := sconn.RBegin(t.Context())
|
||||
require.NoError(t, err)
|
||||
tx2, err := sconn.RBegin(t.Context())
|
||||
require.NoError(t, err)
|
||||
tx3, err := sconn.RBegin(t.Context())
|
||||
require.NoError(t, err)
|
||||
tx1.Commit()
|
||||
tx2.Commit()
|
||||
tx3.Commit()
|
||||
})
|
||||
t.Run("Lock acquiring times out after timeout", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 250*time.Millisecond)
|
||||
defer cancel()
|
||||
sconn.acquireGlobalLock()
|
||||
defer sconn.releaseGlobalLock()
|
||||
_, err := sconn.Begin(ctx)
|
||||
require.Error(t, err)
|
||||
})
|
||||
t.Run("Lock acquires if lock released", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 250*time.Millisecond)
|
||||
defer cancel()
|
||||
sconn.acquireGlobalLock()
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
tx, err := sconn.Begin(ctx)
|
||||
require.NoError(t, err)
|
||||
tx.Commit()
|
||||
wg.Done()
|
||||
}()
|
||||
sconn.releaseGlobalLock()
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
163
pkg/db/safetx.go
163
pkg/db/safetx.go
@@ -1,163 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type SafeTX interface {
|
||||
Query(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error)
|
||||
QueryRow(ctx context.Context, query string, args ...interface{}) (*sql.Row, error)
|
||||
Commit() error
|
||||
Rollback() error
|
||||
}
|
||||
|
||||
// Extends sql.Tx for use with SafeConn
|
||||
type SafeWTX struct {
|
||||
tx *sql.Tx
|
||||
sc *SafeConn
|
||||
}
|
||||
type SafeRTX struct {
|
||||
tx *sql.Tx
|
||||
sc *SafeConn
|
||||
}
|
||||
|
||||
func isWriteOperation(query string) bool {
|
||||
query = strings.TrimSpace(query)
|
||||
query = strings.ToUpper(query)
|
||||
writeOpsRegex := `^(INSERT|UPDATE|DELETE|REPLACE|MERGE|CREATE|DROP|ALTER|TRUNCATE)\s+`
|
||||
re := regexp.MustCompile(writeOpsRegex)
|
||||
return re.MatchString(query)
|
||||
}
|
||||
|
||||
// Query the database inside the transaction
|
||||
func (stx *SafeRTX) Query(
|
||||
ctx context.Context,
|
||||
query string,
|
||||
args ...interface{},
|
||||
) (*sql.Rows, error) {
|
||||
if stx.tx == nil {
|
||||
return nil, errors.New("Cannot query without a transaction")
|
||||
}
|
||||
if isWriteOperation(query) {
|
||||
return nil, errors.New("Cannot query with a write operation")
|
||||
}
|
||||
rows, err := stx.tx.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tx.QueryContext")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// Query the database inside the transaction
|
||||
func (stx *SafeWTX) Query(
|
||||
ctx context.Context,
|
||||
query string,
|
||||
args ...interface{},
|
||||
) (*sql.Rows, error) {
|
||||
if stx.tx == nil {
|
||||
return nil, errors.New("Cannot query without a transaction")
|
||||
}
|
||||
if isWriteOperation(query) {
|
||||
return nil, errors.New("Cannot query with a write operation")
|
||||
}
|
||||
rows, err := stx.tx.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tx.QueryContext")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// Query a row from the database inside the transaction
|
||||
func (stx *SafeRTX) QueryRow(
|
||||
ctx context.Context,
|
||||
query string,
|
||||
args ...interface{},
|
||||
) (*sql.Row, error) {
|
||||
if stx.tx == nil {
|
||||
return nil, errors.New("Cannot query without a transaction")
|
||||
}
|
||||
if isWriteOperation(query) {
|
||||
return nil, errors.New("Cannot query with a write operation")
|
||||
}
|
||||
return stx.tx.QueryRowContext(ctx, query, args...), nil
|
||||
}
|
||||
|
||||
// Query a row from the database inside the transaction
|
||||
func (stx *SafeWTX) QueryRow(
|
||||
ctx context.Context,
|
||||
query string,
|
||||
args ...interface{},
|
||||
) (*sql.Row, error) {
|
||||
if stx.tx == nil {
|
||||
return nil, errors.New("Cannot query without a transaction")
|
||||
}
|
||||
if isWriteOperation(query) {
|
||||
return nil, errors.New("Cannot query with a write operation")
|
||||
}
|
||||
return stx.tx.QueryRowContext(ctx, query, args...), nil
|
||||
}
|
||||
|
||||
// Exec a statement on the database inside the transaction
|
||||
func (stx *SafeWTX) Exec(
|
||||
ctx context.Context,
|
||||
query string,
|
||||
args ...interface{},
|
||||
) (sql.Result, error) {
|
||||
if stx.tx == nil {
|
||||
return nil, errors.New("Cannot exec without a transaction")
|
||||
}
|
||||
res, err := stx.tx.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tx.ExecContext")
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Commit the current transaction and release the read lock
|
||||
func (stx *SafeRTX) Commit() error {
|
||||
if stx.tx == nil {
|
||||
return errors.New("Cannot commit without a transaction")
|
||||
}
|
||||
err := stx.tx.Commit()
|
||||
stx.tx = nil
|
||||
stx.sc.releaseReadLock()
|
||||
return err
|
||||
}
|
||||
|
||||
// Commit the current transaction and release the read lock
|
||||
func (stx *SafeWTX) Commit() error {
|
||||
if stx.tx == nil {
|
||||
return errors.New("Cannot commit without a transaction")
|
||||
}
|
||||
err := stx.tx.Commit()
|
||||
stx.tx = nil
|
||||
stx.sc.releaseReadLock()
|
||||
return err
|
||||
}
|
||||
|
||||
// Abort the current transaction, releasing the read lock
|
||||
func (stx *SafeRTX) Rollback() error {
|
||||
if stx.tx == nil {
|
||||
return errors.New("Cannot rollback without a transaction")
|
||||
}
|
||||
err := stx.tx.Rollback()
|
||||
stx.tx = nil
|
||||
stx.sc.releaseReadLock()
|
||||
return err
|
||||
}
|
||||
|
||||
// Abort the current transaction, releasing the read lock
|
||||
func (stx *SafeWTX) Rollback() error {
|
||||
if stx.tx == nil {
|
||||
return errors.New("Cannot rollback without a transaction")
|
||||
}
|
||||
err := stx.tx.Rollback()
|
||||
stx.tx = nil
|
||||
stx.sc.releaseReadLock()
|
||||
return err
|
||||
}
|
||||
@@ -1,282 +1,24 @@
|
||||
/*! tailwindcss v4.0.3 | MIT License | https://tailwindcss.com */
|
||||
/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Ubuntu+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap");
|
||||
@layer properties;
|
||||
@layer theme, base, components, utilities;
|
||||
@layer theme {
|
||||
:root, :host {
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||||
'Courier New', monospace;
|
||||
--color-red-50: oklch(0.971 0.013 17.38);
|
||||
--color-red-100: oklch(0.936 0.032 17.717);
|
||||
--color-red-200: oklch(0.885 0.062 18.334);
|
||||
--color-red-300: oklch(0.808 0.114 19.571);
|
||||
--color-red-400: oklch(0.704 0.191 22.216);
|
||||
--color-red-500: oklch(0.637 0.237 25.331);
|
||||
--color-red-600: oklch(0.577 0.245 27.325);
|
||||
--color-red-700: oklch(0.505 0.213 27.518);
|
||||
--color-red-800: oklch(0.444 0.177 26.899);
|
||||
--color-red-900: oklch(0.396 0.141 25.723);
|
||||
--color-red-950: oklch(0.258 0.092 26.042);
|
||||
--color-orange-50: oklch(0.98 0.016 73.684);
|
||||
--color-orange-100: oklch(0.954 0.038 75.164);
|
||||
--color-orange-200: oklch(0.901 0.076 70.697);
|
||||
--color-orange-300: oklch(0.837 0.128 66.29);
|
||||
--color-orange-400: oklch(0.75 0.183 55.934);
|
||||
--color-orange-500: oklch(0.705 0.213 47.604);
|
||||
--color-orange-600: oklch(0.646 0.222 41.116);
|
||||
--color-orange-700: oklch(0.553 0.195 38.402);
|
||||
--color-orange-800: oklch(0.47 0.157 37.304);
|
||||
--color-orange-900: oklch(0.408 0.123 38.172);
|
||||
--color-orange-950: oklch(0.266 0.079 36.259);
|
||||
--color-amber-50: oklch(0.987 0.022 95.277);
|
||||
--color-amber-100: oklch(0.962 0.059 95.617);
|
||||
--color-amber-200: oklch(0.924 0.12 95.746);
|
||||
--color-amber-300: oklch(0.879 0.169 91.605);
|
||||
--color-amber-400: oklch(0.828 0.189 84.429);
|
||||
--color-amber-500: oklch(0.769 0.188 70.08);
|
||||
--color-amber-600: oklch(0.666 0.179 58.318);
|
||||
--color-amber-700: oklch(0.555 0.163 48.998);
|
||||
--color-amber-800: oklch(0.473 0.137 46.201);
|
||||
--color-amber-900: oklch(0.414 0.112 45.904);
|
||||
--color-amber-950: oklch(0.279 0.077 45.635);
|
||||
--color-yellow-50: oklch(0.987 0.026 102.212);
|
||||
--color-yellow-100: oklch(0.973 0.071 103.193);
|
||||
--color-yellow-200: oklch(0.945 0.129 101.54);
|
||||
--color-yellow-300: oklch(0.905 0.182 98.111);
|
||||
--color-yellow-400: oklch(0.852 0.199 91.936);
|
||||
--color-yellow-500: oklch(0.795 0.184 86.047);
|
||||
--color-yellow-600: oklch(0.681 0.162 75.834);
|
||||
--color-yellow-700: oklch(0.554 0.135 66.442);
|
||||
--color-yellow-800: oklch(0.476 0.114 61.907);
|
||||
--color-yellow-900: oklch(0.421 0.095 57.708);
|
||||
--color-yellow-950: oklch(0.286 0.066 53.813);
|
||||
--color-lime-50: oklch(0.986 0.031 120.757);
|
||||
--color-lime-100: oklch(0.967 0.067 122.328);
|
||||
--color-lime-200: oklch(0.938 0.127 124.321);
|
||||
--color-lime-300: oklch(0.897 0.196 126.665);
|
||||
--color-lime-400: oklch(0.841 0.238 128.85);
|
||||
--color-lime-500: oklch(0.768 0.233 130.85);
|
||||
--color-lime-600: oklch(0.648 0.2 131.684);
|
||||
--color-lime-700: oklch(0.532 0.157 131.589);
|
||||
--color-lime-800: oklch(0.453 0.124 130.933);
|
||||
--color-lime-900: oklch(0.405 0.101 131.063);
|
||||
--color-lime-950: oklch(0.274 0.072 132.109);
|
||||
--color-green-50: oklch(0.982 0.018 155.826);
|
||||
--color-green-100: oklch(0.962 0.044 156.743);
|
||||
--color-green-200: oklch(0.925 0.084 155.995);
|
||||
--color-green-300: oklch(0.871 0.15 154.449);
|
||||
--color-green-400: oklch(0.792 0.209 151.711);
|
||||
--color-green-500: oklch(0.723 0.219 149.579);
|
||||
--color-green-600: oklch(0.627 0.194 149.214);
|
||||
--color-green-700: oklch(0.527 0.154 150.069);
|
||||
--color-green-800: oklch(0.448 0.119 151.328);
|
||||
--color-green-900: oklch(0.393 0.095 152.535);
|
||||
--color-green-950: oklch(0.266 0.065 152.934);
|
||||
--color-emerald-50: oklch(0.979 0.021 166.113);
|
||||
--color-emerald-100: oklch(0.95 0.052 163.051);
|
||||
--color-emerald-200: oklch(0.905 0.093 164.15);
|
||||
--color-emerald-300: oklch(0.845 0.143 164.978);
|
||||
--color-emerald-400: oklch(0.765 0.177 163.223);
|
||||
--color-emerald-500: oklch(0.696 0.17 162.48);
|
||||
--color-emerald-600: oklch(0.596 0.145 163.225);
|
||||
--color-emerald-700: oklch(0.508 0.118 165.612);
|
||||
--color-emerald-800: oklch(0.432 0.095 166.913);
|
||||
--color-emerald-900: oklch(0.378 0.077 168.94);
|
||||
--color-emerald-950: oklch(0.262 0.051 172.552);
|
||||
--color-teal-50: oklch(0.984 0.014 180.72);
|
||||
--color-teal-100: oklch(0.953 0.051 180.801);
|
||||
--color-teal-200: oklch(0.91 0.096 180.426);
|
||||
--color-teal-300: oklch(0.855 0.138 181.071);
|
||||
--color-teal-400: oklch(0.777 0.152 181.912);
|
||||
--color-teal-500: oklch(0.704 0.14 182.503);
|
||||
--color-teal-600: oklch(0.6 0.118 184.704);
|
||||
--color-teal-700: oklch(0.511 0.096 186.391);
|
||||
--color-teal-800: oklch(0.437 0.078 188.216);
|
||||
--color-teal-900: oklch(0.386 0.063 188.416);
|
||||
--color-teal-950: oklch(0.277 0.046 192.524);
|
||||
--color-cyan-50: oklch(0.984 0.019 200.873);
|
||||
--color-cyan-100: oklch(0.956 0.045 203.388);
|
||||
--color-cyan-200: oklch(0.917 0.08 205.041);
|
||||
--color-cyan-300: oklch(0.865 0.127 207.078);
|
||||
--color-cyan-400: oklch(0.789 0.154 211.53);
|
||||
--color-cyan-500: oklch(0.715 0.143 215.221);
|
||||
--color-cyan-600: oklch(0.609 0.126 221.723);
|
||||
--color-cyan-700: oklch(0.52 0.105 223.128);
|
||||
--color-cyan-800: oklch(0.45 0.085 224.283);
|
||||
--color-cyan-900: oklch(0.398 0.07 227.392);
|
||||
--color-cyan-950: oklch(0.302 0.056 229.695);
|
||||
--color-sky-50: oklch(0.977 0.013 236.62);
|
||||
--color-sky-100: oklch(0.951 0.026 236.824);
|
||||
--color-sky-200: oklch(0.901 0.058 230.902);
|
||||
--color-sky-300: oklch(0.828 0.111 230.318);
|
||||
--color-sky-400: oklch(0.746 0.16 232.661);
|
||||
--color-sky-500: oklch(0.685 0.169 237.323);
|
||||
--color-sky-600: oklch(0.588 0.158 241.966);
|
||||
--color-sky-700: oklch(0.5 0.134 242.749);
|
||||
--color-sky-800: oklch(0.443 0.11 240.79);
|
||||
--color-sky-900: oklch(0.391 0.09 240.876);
|
||||
--color-sky-950: oklch(0.293 0.066 243.157);
|
||||
--color-blue-50: oklch(0.97 0.014 254.604);
|
||||
--color-blue-100: oklch(0.932 0.032 255.585);
|
||||
--color-blue-200: oklch(0.882 0.059 254.128);
|
||||
--color-blue-300: oklch(0.809 0.105 251.813);
|
||||
--color-blue-400: oklch(0.707 0.165 254.624);
|
||||
--color-blue-500: oklch(0.623 0.214 259.815);
|
||||
--color-blue-600: oklch(0.546 0.245 262.881);
|
||||
--color-blue-700: oklch(0.488 0.243 264.376);
|
||||
--color-blue-800: oklch(0.424 0.199 265.638);
|
||||
--color-blue-900: oklch(0.379 0.146 265.522);
|
||||
--color-blue-950: oklch(0.282 0.091 267.935);
|
||||
--color-indigo-50: oklch(0.962 0.018 272.314);
|
||||
--color-indigo-100: oklch(0.93 0.034 272.788);
|
||||
--color-indigo-200: oklch(0.87 0.065 274.039);
|
||||
--color-indigo-300: oklch(0.785 0.115 274.713);
|
||||
--color-indigo-400: oklch(0.673 0.182 276.935);
|
||||
--color-indigo-500: oklch(0.585 0.233 277.117);
|
||||
--color-indigo-600: oklch(0.511 0.262 276.966);
|
||||
--color-indigo-700: oklch(0.457 0.24 277.023);
|
||||
--color-indigo-800: oklch(0.398 0.195 277.366);
|
||||
--color-indigo-900: oklch(0.359 0.144 278.697);
|
||||
--color-indigo-950: oklch(0.257 0.09 281.288);
|
||||
--color-violet-50: oklch(0.969 0.016 293.756);
|
||||
--color-violet-100: oklch(0.943 0.029 294.588);
|
||||
--color-violet-200: oklch(0.894 0.057 293.283);
|
||||
--color-violet-300: oklch(0.811 0.111 293.571);
|
||||
--color-violet-400: oklch(0.702 0.183 293.541);
|
||||
--color-violet-500: oklch(0.606 0.25 292.717);
|
||||
--color-violet-600: oklch(0.541 0.281 293.009);
|
||||
--color-violet-700: oklch(0.491 0.27 292.581);
|
||||
--color-violet-800: oklch(0.432 0.232 292.759);
|
||||
--color-violet-900: oklch(0.38 0.189 293.745);
|
||||
--color-violet-950: oklch(0.283 0.141 291.089);
|
||||
--color-purple-50: oklch(0.977 0.014 308.299);
|
||||
--color-purple-100: oklch(0.946 0.033 307.174);
|
||||
--color-purple-200: oklch(0.902 0.063 306.703);
|
||||
--color-purple-300: oklch(0.827 0.119 306.383);
|
||||
--color-purple-400: oklch(0.714 0.203 305.504);
|
||||
--color-purple-500: oklch(0.627 0.265 303.9);
|
||||
--color-purple-600: oklch(0.558 0.288 302.321);
|
||||
--color-purple-700: oklch(0.496 0.265 301.924);
|
||||
--color-purple-800: oklch(0.438 0.218 303.724);
|
||||
--color-purple-900: oklch(0.381 0.176 304.987);
|
||||
--color-purple-950: oklch(0.291 0.149 302.717);
|
||||
--color-fuchsia-50: oklch(0.977 0.017 320.058);
|
||||
--color-fuchsia-100: oklch(0.952 0.037 318.852);
|
||||
--color-fuchsia-200: oklch(0.903 0.076 319.62);
|
||||
--color-fuchsia-300: oklch(0.833 0.145 321.434);
|
||||
--color-fuchsia-400: oklch(0.74 0.238 322.16);
|
||||
--color-fuchsia-500: oklch(0.667 0.295 322.15);
|
||||
--color-fuchsia-600: oklch(0.591 0.293 322.896);
|
||||
--color-fuchsia-700: oklch(0.518 0.253 323.949);
|
||||
--color-fuchsia-800: oklch(0.452 0.211 324.591);
|
||||
--color-fuchsia-900: oklch(0.401 0.17 325.612);
|
||||
--color-fuchsia-950: oklch(0.293 0.136 325.661);
|
||||
--color-pink-50: oklch(0.971 0.014 343.198);
|
||||
--color-pink-100: oklch(0.948 0.028 342.258);
|
||||
--color-pink-200: oklch(0.899 0.061 343.231);
|
||||
--color-pink-300: oklch(0.823 0.12 346.018);
|
||||
--color-pink-400: oklch(0.718 0.202 349.761);
|
||||
--color-pink-500: oklch(0.656 0.241 354.308);
|
||||
--color-pink-600: oklch(0.592 0.249 0.584);
|
||||
--color-pink-700: oklch(0.525 0.223 3.958);
|
||||
--color-pink-800: oklch(0.459 0.187 3.815);
|
||||
--color-pink-900: oklch(0.408 0.153 2.432);
|
||||
--color-pink-950: oklch(0.284 0.109 3.907);
|
||||
--color-rose-50: oklch(0.969 0.015 12.422);
|
||||
--color-rose-100: oklch(0.941 0.03 12.58);
|
||||
--color-rose-200: oklch(0.892 0.058 10.001);
|
||||
--color-rose-300: oklch(0.81 0.117 11.638);
|
||||
--color-rose-400: oklch(0.712 0.194 13.428);
|
||||
--color-rose-500: oklch(0.645 0.246 16.439);
|
||||
--color-rose-600: oklch(0.586 0.253 17.585);
|
||||
--color-rose-700: oklch(0.514 0.222 16.935);
|
||||
--color-rose-800: oklch(0.455 0.188 13.697);
|
||||
--color-rose-900: oklch(0.41 0.159 10.272);
|
||||
--color-rose-950: oklch(0.271 0.105 12.094);
|
||||
--color-slate-50: oklch(0.984 0.003 247.858);
|
||||
--color-slate-100: oklch(0.968 0.007 247.896);
|
||||
--color-slate-200: oklch(0.929 0.013 255.508);
|
||||
--color-slate-300: oklch(0.869 0.022 252.894);
|
||||
--color-slate-400: oklch(0.704 0.04 256.788);
|
||||
--color-slate-500: oklch(0.554 0.046 257.417);
|
||||
--color-slate-600: oklch(0.446 0.043 257.281);
|
||||
--color-slate-700: oklch(0.372 0.044 257.287);
|
||||
--color-slate-800: oklch(0.279 0.041 260.031);
|
||||
--color-slate-900: oklch(0.208 0.042 265.755);
|
||||
--color-slate-950: oklch(0.129 0.042 264.695);
|
||||
--color-gray-50: oklch(0.985 0.002 247.839);
|
||||
--color-gray-100: oklch(0.967 0.003 264.542);
|
||||
--color-gray-200: oklch(0.928 0.006 264.531);
|
||||
--color-gray-300: oklch(0.872 0.01 258.338);
|
||||
--color-gray-400: oklch(0.707 0.022 261.325);
|
||||
--color-gray-500: oklch(0.551 0.027 264.364);
|
||||
--color-gray-600: oklch(0.446 0.03 256.802);
|
||||
--color-gray-700: oklch(0.373 0.034 259.733);
|
||||
--color-gray-800: oklch(0.278 0.033 256.848);
|
||||
--color-gray-900: oklch(0.21 0.034 264.665);
|
||||
--color-gray-950: oklch(0.13 0.028 261.692);
|
||||
--color-zinc-50: oklch(0.985 0 0);
|
||||
--color-zinc-100: oklch(0.967 0.001 286.375);
|
||||
--color-zinc-200: oklch(0.92 0.004 286.32);
|
||||
--color-zinc-300: oklch(0.871 0.006 286.286);
|
||||
--color-zinc-400: oklch(0.705 0.015 286.067);
|
||||
--color-zinc-500: oklch(0.552 0.016 285.938);
|
||||
--color-zinc-600: oklch(0.442 0.017 285.786);
|
||||
--color-zinc-700: oklch(0.37 0.013 285.805);
|
||||
--color-zinc-800: oklch(0.274 0.006 286.033);
|
||||
--color-zinc-900: oklch(0.21 0.006 285.885);
|
||||
--color-zinc-950: oklch(0.141 0.005 285.823);
|
||||
--color-neutral-50: oklch(0.985 0 0);
|
||||
--color-neutral-100: oklch(0.97 0 0);
|
||||
--color-neutral-200: oklch(0.922 0 0);
|
||||
--color-neutral-300: oklch(0.87 0 0);
|
||||
--color-neutral-400: oklch(0.708 0 0);
|
||||
--color-neutral-500: oklch(0.556 0 0);
|
||||
--color-neutral-600: oklch(0.439 0 0);
|
||||
--color-neutral-700: oklch(0.371 0 0);
|
||||
--color-neutral-800: oklch(0.269 0 0);
|
||||
--color-neutral-900: oklch(0.205 0 0);
|
||||
--color-neutral-950: oklch(0.145 0 0);
|
||||
--color-stone-50: oklch(0.985 0.001 106.423);
|
||||
--color-stone-100: oklch(0.97 0.001 106.424);
|
||||
--color-stone-200: oklch(0.923 0.003 48.717);
|
||||
--color-stone-300: oklch(0.869 0.005 56.366);
|
||||
--color-stone-400: oklch(0.709 0.01 56.259);
|
||||
--color-stone-500: oklch(0.553 0.013 58.071);
|
||||
--color-stone-600: oklch(0.444 0.011 73.639);
|
||||
--color-stone-700: oklch(0.374 0.01 67.558);
|
||||
--color-stone-800: oklch(0.268 0.007 34.298);
|
||||
--color-stone-900: oklch(0.216 0.006 56.043);
|
||||
--color-stone-950: oklch(0.147 0.004 49.25);
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||
monospace;
|
||||
--color-blue-500: oklch(62.3% 0.214 259.815);
|
||||
--color-gray-200: oklch(92.8% 0.006 264.531);
|
||||
--color-black: #000;
|
||||
--color-white: #fff;
|
||||
--spacing: 0.25rem;
|
||||
--breakpoint-sm: 40rem;
|
||||
--breakpoint-md: 48rem;
|
||||
--breakpoint-lg: 64rem;
|
||||
--breakpoint-xl: 80rem;
|
||||
--breakpoint-2xl: 96rem;
|
||||
--container-3xs: 16rem;
|
||||
--container-2xs: 18rem;
|
||||
--container-xs: 20rem;
|
||||
--container-sm: 24rem;
|
||||
--container-md: 28rem;
|
||||
--container-lg: 32rem;
|
||||
--container-xl: 36rem;
|
||||
--container-2xl: 42rem;
|
||||
--container-3xl: 48rem;
|
||||
--container-4xl: 56rem;
|
||||
--container-5xl: 64rem;
|
||||
--container-6xl: 72rem;
|
||||
--container-7xl: 80rem;
|
||||
--text-xs: 0.75rem;
|
||||
--text-xs--line-height: calc(1 / 0.75);
|
||||
--text-sm: 0.875rem;
|
||||
--text-sm--line-height: calc(1.25 / 0.875);
|
||||
--text-base: 1rem;
|
||||
--text-base--line-height: calc(1.5 / 1);
|
||||
--text-lg: 1.125rem;
|
||||
--text-lg--line-height: calc(1.75 / 1.125);
|
||||
--text-xl: 1.25rem;
|
||||
@@ -287,115 +29,24 @@
|
||||
--text-3xl--line-height: calc(2.25 / 1.875);
|
||||
--text-4xl: 2.25rem;
|
||||
--text-4xl--line-height: calc(2.5 / 2.25);
|
||||
--text-5xl: 3rem;
|
||||
--text-5xl--line-height: 1;
|
||||
--text-6xl: 3.75rem;
|
||||
--text-6xl--line-height: 1;
|
||||
--text-7xl: 4.5rem;
|
||||
--text-7xl--line-height: 1;
|
||||
--text-8xl: 6rem;
|
||||
--text-8xl--line-height: 1;
|
||||
--text-9xl: 8rem;
|
||||
--text-9xl--line-height: 1;
|
||||
--font-weight-thin: 100;
|
||||
--font-weight-extralight: 200;
|
||||
--font-weight-light: 300;
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
--font-weight-extrabold: 800;
|
||||
--font-weight-black: 900;
|
||||
--tracking-tighter: -0.05em;
|
||||
--tracking-tight: -0.025em;
|
||||
--tracking-normal: 0em;
|
||||
--tracking-wide: 0.025em;
|
||||
--tracking-wider: 0.05em;
|
||||
--tracking-widest: 0.1em;
|
||||
--leading-tight: 1.25;
|
||||
--leading-snug: 1.375;
|
||||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.625;
|
||||
--leading-loose: 2;
|
||||
--radius-xs: 0.125rem;
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-xl: 0.75rem;
|
||||
--radius-2xl: 1rem;
|
||||
--radius-3xl: 1.5rem;
|
||||
--radius-4xl: 2rem;
|
||||
--shadow-2xs: 0 1px rgb(0 0 0 / 0.05);
|
||||
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
|
||||
--inset-shadow-2xs: inset 0 1px rgb(0 0 0 / 0.05);
|
||||
--inset-shadow-xs: inset 0 1px 1px rgb(0 0 0 / 0.05);
|
||||
--inset-shadow-sm: inset 0 2px 4px rgb(0 0 0 / 0.05);
|
||||
--drop-shadow-xs: 0 1px 1px rgb(0 0 0 / 0.05);
|
||||
--drop-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.15);
|
||||
--drop-shadow-md: 0 3px 3px rgb(0 0 0 / 0.12);
|
||||
--drop-shadow-lg: 0 4px 4px rgb(0 0 0 / 0.15);
|
||||
--drop-shadow-xl: 0 9px 7px rgb(0 0 0 / 0.1);
|
||||
--drop-shadow-2xl: 0 25px 25px rgb(0 0 0 / 0.15);
|
||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--animate-spin: spin 1s linear infinite;
|
||||
--animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||
--animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
--animate-bounce: bounce 1s infinite;
|
||||
--blur-xs: 4px;
|
||||
--blur-sm: 8px;
|
||||
--blur-md: 12px;
|
||||
--blur-lg: 16px;
|
||||
--blur-xl: 24px;
|
||||
--blur-2xl: 40px;
|
||||
--blur-3xl: 64px;
|
||||
--perspective-dramatic: 100px;
|
||||
--perspective-near: 300px;
|
||||
--perspective-normal: 500px;
|
||||
--perspective-midrange: 800px;
|
||||
--perspective-distant: 1200px;
|
||||
--aspect-video: 16 / 9;
|
||||
--default-transition-duration: 150ms;
|
||||
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--default-font-family: var(--font-sans);
|
||||
--default-font-feature-settings: var(--font-sans--font-feature-settings);
|
||||
--default-font-variation-settings: var(--font-sans--font-variation-settings);
|
||||
--default-mono-font-family: var(--font-mono);
|
||||
--default-mono-font-feature-settings: var(--font-mono--font-feature-settings);
|
||||
--default-mono-font-variation-settings: var(--font-mono--font-variation-settings);
|
||||
--color-rosewater: var(--rosewater);
|
||||
--color-flamingo: var(--flamingo);
|
||||
--color-pink: var(--pink);
|
||||
--color-mauve: var(--mauve);
|
||||
--color-red: var(--red);
|
||||
--color-dark-red: var(--dark-red);
|
||||
--color-maroon: var(--maroon);
|
||||
--color-peach: var(--peach);
|
||||
--color-yellow: var(--yellow);
|
||||
--color-green: var(--green);
|
||||
--color-teal: var(--teal);
|
||||
--color-sky: var(--sky);
|
||||
--color-sapphire: var(--sapphire);
|
||||
--color-blue: var(--blue);
|
||||
--color-lavender: var(--lavender);
|
||||
--color-text: var(--text);
|
||||
--color-subtext1: var(--subtext1);
|
||||
--color-subtext0: var(--subtext0);
|
||||
--color-overlay2: var(--overlay2);
|
||||
--color-overlay1: var(--overlay1);
|
||||
--color-overlay0: var(--overlay0);
|
||||
--color-surface2: var(--surface2);
|
||||
--color-surface1: var(--surface1);
|
||||
--color-surface0: var(--surface0);
|
||||
--color-base: var(--base);
|
||||
--color-mantle: var(--mantle);
|
||||
--color-crust: var(--crust);
|
||||
}
|
||||
}
|
||||
@layer base {
|
||||
@@ -409,14 +60,11 @@
|
||||
line-height: 1.5;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
tab-size: 4;
|
||||
font-family: var( --default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji' );
|
||||
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji');
|
||||
font-feature-settings: var(--default-font-feature-settings, normal);
|
||||
font-variation-settings: var(--default-font-variation-settings, normal);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
body {
|
||||
line-height: inherit;
|
||||
}
|
||||
hr {
|
||||
height: 0;
|
||||
color: inherit;
|
||||
@@ -439,7 +87,7 @@
|
||||
font-weight: bolder;
|
||||
}
|
||||
code, kbd, samp, pre {
|
||||
font-family: var( --default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace );
|
||||
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace);
|
||||
font-feature-settings: var(--default-mono-font-feature-settings, normal);
|
||||
font-variation-settings: var(--default-mono-font-variation-settings, normal);
|
||||
font-size: 1em;
|
||||
@@ -505,7 +153,14 @@
|
||||
}
|
||||
::placeholder {
|
||||
opacity: 1;
|
||||
color: color-mix(in oklab, currentColor 50%, transparent);
|
||||
}
|
||||
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
|
||||
::placeholder {
|
||||
color: currentcolor;
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
color: color-mix(in oklab, currentcolor 50%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
textarea {
|
||||
resize: vertical;
|
||||
@@ -526,6 +181,9 @@
|
||||
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
|
||||
padding-block: 0;
|
||||
}
|
||||
::-webkit-calendar-picker-indicator {
|
||||
line-height: 1;
|
||||
}
|
||||
:-moz-ui-invalid {
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -553,7 +211,7 @@
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
@@ -701,7 +359,7 @@
|
||||
.table {
|
||||
display: table;
|
||||
}
|
||||
.aspect-\[2\/3\] {
|
||||
.aspect-2\/3 {
|
||||
aspect-ratio: 2/3;
|
||||
}
|
||||
.size-5 {
|
||||
@@ -799,7 +457,7 @@
|
||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||
}
|
||||
.transform {
|
||||
transform: var(--tw-rotate-x) var(--tw-rotate-y) var(--tw-rotate-z) var(--tw-skew-x) var(--tw-skew-y);
|
||||
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
|
||||
}
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
@@ -837,12 +495,6 @@
|
||||
.gap-8 {
|
||||
gap: calc(var(--spacing) * 8);
|
||||
}
|
||||
.gap-x-1 {
|
||||
column-gap: calc(var(--spacing) * 1);
|
||||
}
|
||||
.gap-x-2 {
|
||||
column-gap: calc(var(--spacing) * 2);
|
||||
}
|
||||
.space-y-1 {
|
||||
:where(& > :not(:last-child)) {
|
||||
--tw-space-y-reverse: 0;
|
||||
@@ -857,8 +509,11 @@
|
||||
margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)));
|
||||
}
|
||||
}
|
||||
.gap-y-4 {
|
||||
row-gap: calc(var(--spacing) * 4);
|
||||
.gap-x-1 {
|
||||
column-gap: calc(var(--spacing) * 1);
|
||||
}
|
||||
.gap-x-2 {
|
||||
column-gap: calc(var(--spacing) * 2);
|
||||
}
|
||||
.space-x-2 {
|
||||
:where(& > :not(:last-child)) {
|
||||
@@ -874,6 +529,9 @@
|
||||
margin-inline-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse)));
|
||||
}
|
||||
}
|
||||
.gap-y-4 {
|
||||
row-gap: calc(var(--spacing) * 4);
|
||||
}
|
||||
.divide-y {
|
||||
:where(& > :not(:last-child)) {
|
||||
--tw-divide-y-reverse: 0;
|
||||
@@ -965,7 +623,10 @@
|
||||
background-color: var(--overlay0);
|
||||
}
|
||||
.bg-overlay0\/55 {
|
||||
background-color: color-mix(in oklab, var(--overlay0) 55%, transparent);
|
||||
background-color: var(--overlay0);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--overlay0) 55%, transparent);
|
||||
}
|
||||
}
|
||||
.bg-overlay2 {
|
||||
background-color: var(--overlay2);
|
||||
@@ -1161,10 +822,13 @@
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
}
|
||||
.shadow-black {
|
||||
--tw-shadow-color: var(--color-black);
|
||||
--tw-shadow-color: #000;
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
--tw-shadow-color: color-mix(in oklab, var(--color-black) var(--tw-shadow-alpha), transparent);
|
||||
}
|
||||
}
|
||||
.transition {
|
||||
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter;
|
||||
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
|
||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||
}
|
||||
@@ -1245,7 +909,10 @@
|
||||
.hover\:bg-blue\/75 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: color-mix(in oklab, var(--blue) 75%, transparent);
|
||||
background-color: var(--blue);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--blue) 75%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1259,7 +926,10 @@
|
||||
.hover\:bg-green\/75 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: color-mix(in oklab, var(--green) 75%, transparent);
|
||||
background-color: var(--green);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--green) 75%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1273,21 +943,30 @@
|
||||
.hover\:bg-mauve\/75 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: color-mix(in oklab, var(--mauve) 75%, transparent);
|
||||
background-color: var(--mauve);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--mauve) 75%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-red\/25 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: color-mix(in oklab, var(--red) 25%, transparent);
|
||||
background-color: var(--red);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--red) 25%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-sapphire\/75 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: color-mix(in oklab, var(--sapphire) 75%, transparent);
|
||||
background-color: var(--sapphire);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--sapphire) 75%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1308,7 +987,10 @@
|
||||
.hover\:bg-teal\/75 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: color-mix(in oklab, var(--teal) 75%, transparent);
|
||||
background-color: var(--teal);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--teal) 75%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1322,7 +1004,10 @@
|
||||
.hover\:text-overlay2\/75 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
color: color-mix(in oklab, var(--overlay2) 75%, transparent);
|
||||
color: var(--overlay2);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
color: color-mix(in oklab, var(--overlay2) 75%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1359,7 +1044,7 @@
|
||||
}
|
||||
.focus\:ring-2 {
|
||||
&:focus {
|
||||
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentColor);
|
||||
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
}
|
||||
}
|
||||
@@ -1391,12 +1076,18 @@
|
||||
}
|
||||
.disabled\:bg-blue\/60 {
|
||||
&:disabled {
|
||||
background-color: color-mix(in oklab, var(--blue) 60%, transparent);
|
||||
background-color: var(--blue);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--blue) 60%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled\:bg-green\/60 {
|
||||
&:disabled {
|
||||
background-color: color-mix(in oklab, var(--green) 60%, transparent);
|
||||
background-color: var(--green);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--green) 60%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled\:opacity-50 {
|
||||
@@ -1745,32 +1436,6 @@
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@keyframes ping {
|
||||
75%, 100% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
@keyframes bounce {
|
||||
0%, 100% {
|
||||
transform: translateY(-25%);
|
||||
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
|
||||
}
|
||||
50% {
|
||||
transform: none;
|
||||
animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
}
|
||||
@property --tw-translate-x {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
@@ -1789,27 +1454,22 @@
|
||||
@property --tw-rotate-x {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: rotateX(0);
|
||||
}
|
||||
@property --tw-rotate-y {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: rotateY(0);
|
||||
}
|
||||
@property --tw-rotate-z {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: rotateZ(0);
|
||||
}
|
||||
@property --tw-skew-x {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: skewX(0);
|
||||
}
|
||||
@property --tw-skew-y {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: skewY(0);
|
||||
}
|
||||
@property --tw-space-y-reverse {
|
||||
syntax: "*";
|
||||
@@ -1852,6 +1512,11 @@
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-shadow-alpha {
|
||||
syntax: "<percentage>";
|
||||
inherits: false;
|
||||
initial-value: 100%;
|
||||
}
|
||||
@property --tw-inset-shadow {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
@@ -1861,6 +1526,11 @@
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-inset-shadow-alpha {
|
||||
syntax: "<percentage>";
|
||||
inherits: false;
|
||||
initial-value: 100%;
|
||||
}
|
||||
@property --tw-ring-color {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
@@ -1911,3 +1581,41 @@
|
||||
initial-value: "";
|
||||
inherits: false;
|
||||
}
|
||||
@layer properties {
|
||||
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
|
||||
*, ::before, ::after, ::backdrop {
|
||||
--tw-translate-x: 0;
|
||||
--tw-translate-y: 0;
|
||||
--tw-translate-z: 0;
|
||||
--tw-rotate-x: initial;
|
||||
--tw-rotate-y: initial;
|
||||
--tw-rotate-z: initial;
|
||||
--tw-skew-x: initial;
|
||||
--tw-skew-y: initial;
|
||||
--tw-space-y-reverse: 0;
|
||||
--tw-space-x-reverse: 0;
|
||||
--tw-divide-y-reverse: 0;
|
||||
--tw-border-style: solid;
|
||||
--tw-leading: initial;
|
||||
--tw-font-weight: initial;
|
||||
--tw-tracking: initial;
|
||||
--tw-shadow: 0 0 #0000;
|
||||
--tw-shadow-color: initial;
|
||||
--tw-shadow-alpha: 100%;
|
||||
--tw-inset-shadow: 0 0 #0000;
|
||||
--tw-inset-shadow-color: initial;
|
||||
--tw-inset-shadow-alpha: 100%;
|
||||
--tw-ring-color: initial;
|
||||
--tw-ring-shadow: 0 0 #0000;
|
||||
--tw-inset-ring-color: initial;
|
||||
--tw-inset-ring-shadow: 0 0 #0000;
|
||||
--tw-ring-inset: initial;
|
||||
--tw-ring-offset-width: 0px;
|
||||
--tw-ring-offset-color: #fff;
|
||||
--tw-ring-offset-shadow: 0 0 #0000;
|
||||
--tw-duration: initial;
|
||||
--tw-ease: initial;
|
||||
--tw-content: "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"projectreshoot/internal/models"
|
||||
"projectreshoot/pkg/config"
|
||||
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Generates an access token for the provided user
|
||||
func GenerateAccessToken(
|
||||
config *config.Config,
|
||||
user *models.User,
|
||||
fresh bool,
|
||||
rememberMe bool,
|
||||
) (tokenStr string, exp int64, err error) {
|
||||
issuedAt := time.Now().Unix()
|
||||
expiresAt := issuedAt + (config.AccessTokenExpiry * 60)
|
||||
var freshExpiresAt int64
|
||||
if fresh {
|
||||
freshExpiresAt = issuedAt + (config.TokenFreshTime * 60)
|
||||
} else {
|
||||
freshExpiresAt = issuedAt
|
||||
}
|
||||
var ttl string
|
||||
if rememberMe {
|
||||
ttl = "exp"
|
||||
} else {
|
||||
ttl = "session"
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256,
|
||||
jwt.MapClaims{
|
||||
"iss": config.TrustedHost,
|
||||
"scope": "access",
|
||||
"ttl": ttl,
|
||||
"jti": uuid.New(),
|
||||
"iat": issuedAt,
|
||||
"exp": expiresAt,
|
||||
"fresh": freshExpiresAt,
|
||||
"sub": user.ID,
|
||||
})
|
||||
|
||||
signedToken, err := token.SignedString([]byte(config.SecretKey))
|
||||
if err != nil {
|
||||
return "", 0, errors.Wrap(err, "token.SignedString")
|
||||
}
|
||||
return signedToken, expiresAt, nil
|
||||
}
|
||||
|
||||
// Generates a refresh token for the provided user
|
||||
func GenerateRefreshToken(
|
||||
config *config.Config,
|
||||
user *models.User,
|
||||
rememberMe bool,
|
||||
) (tokenStr string, exp int64, err error) {
|
||||
issuedAt := time.Now().Unix()
|
||||
expiresAt := issuedAt + (config.RefreshTokenExpiry * 60)
|
||||
var ttl string
|
||||
if rememberMe {
|
||||
ttl = "exp"
|
||||
} else {
|
||||
ttl = "session"
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256,
|
||||
jwt.MapClaims{
|
||||
"iss": config.TrustedHost,
|
||||
"scope": "refresh",
|
||||
"ttl": ttl,
|
||||
"jti": uuid.New(),
|
||||
"iat": issuedAt,
|
||||
"exp": expiresAt,
|
||||
"sub": user.ID,
|
||||
})
|
||||
|
||||
signedToken, err := token.SignedString([]byte(config.SecretKey))
|
||||
if err != nil {
|
||||
return "", 0, errors.Wrap(err, "token.SignedString")
|
||||
}
|
||||
return signedToken, expiresAt, nil
|
||||
}
|
||||
268
pkg/jwt/parse.go
268
pkg/jwt/parse.go
@@ -1,268 +0,0 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"projectreshoot/pkg/config"
|
||||
"projectreshoot/pkg/db"
|
||||
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Parse an access token and return a struct with all the claims. Does validation on
|
||||
// all the claims, including checking if it is expired, has a valid issuer, and
|
||||
// has the correct scope.
|
||||
func ParseAccessToken(
|
||||
config *config.Config,
|
||||
ctx context.Context,
|
||||
tx db.SafeTX,
|
||||
tokenString string,
|
||||
) (*AccessToken, error) {
|
||||
if tokenString == "" {
|
||||
return nil, errors.New("Access token string not provided")
|
||||
}
|
||||
claims, err := parseToken(config.SecretKey, tokenString)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "parseToken")
|
||||
}
|
||||
expiry, err := checkTokenExpired(claims["exp"])
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "checkTokenExpired")
|
||||
}
|
||||
issuer, err := checkTokenIssuer(config.TrustedHost, claims["iss"])
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "checkTokenIssuer")
|
||||
}
|
||||
ttl, err := getTokenTTL(claims["ttl"])
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "getTokenTTL")
|
||||
}
|
||||
scope, err := getTokenScope(claims["scope"])
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "getTokenScope")
|
||||
}
|
||||
if scope != "access" {
|
||||
return nil, errors.New("Token is not an Access token")
|
||||
}
|
||||
issuedAt, err := getIssuedTime(claims["iat"])
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "getIssuedTime")
|
||||
}
|
||||
subject, err := getTokenSubject(claims["sub"])
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "getTokenSubject")
|
||||
}
|
||||
fresh, err := getFreshTime(claims["fresh"])
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "getFreshTime")
|
||||
}
|
||||
jti, err := getTokenJTI(claims["jti"])
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "getTokenJTI")
|
||||
}
|
||||
|
||||
token := &AccessToken{
|
||||
ISS: issuer,
|
||||
TTL: ttl,
|
||||
EXP: expiry,
|
||||
IAT: issuedAt,
|
||||
SUB: subject,
|
||||
Fresh: fresh,
|
||||
JTI: jti,
|
||||
Scope: scope,
|
||||
}
|
||||
|
||||
valid, err := CheckTokenNotRevoked(ctx, tx, token)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "CheckTokenNotRevoked")
|
||||
}
|
||||
if !valid {
|
||||
return nil, errors.New("Token has been revoked")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Parse a refresh token and return a struct with all the claims. Does validation on
|
||||
// all the claims, including checking if it is expired, has a valid issuer, and
|
||||
// has the correct scope.
|
||||
func ParseRefreshToken(
|
||||
config *config.Config,
|
||||
ctx context.Context,
|
||||
tx db.SafeTX,
|
||||
tokenString string,
|
||||
) (*RefreshToken, error) {
|
||||
if tokenString == "" {
|
||||
return nil, errors.New("Refresh token string not provided")
|
||||
}
|
||||
claims, err := parseToken(config.SecretKey, tokenString)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "parseToken")
|
||||
}
|
||||
expiry, err := checkTokenExpired(claims["exp"])
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "checkTokenExpired")
|
||||
}
|
||||
issuer, err := checkTokenIssuer(config.TrustedHost, claims["iss"])
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "checkTokenIssuer")
|
||||
}
|
||||
ttl, err := getTokenTTL(claims["ttl"])
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "getTokenTTL")
|
||||
}
|
||||
scope, err := getTokenScope(claims["scope"])
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "getTokenScope")
|
||||
}
|
||||
if scope != "refresh" {
|
||||
return nil, errors.New("Token is not an Refresh token")
|
||||
}
|
||||
issuedAt, err := getIssuedTime(claims["iat"])
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "getIssuedTime")
|
||||
}
|
||||
subject, err := getTokenSubject(claims["sub"])
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "getTokenSubject")
|
||||
}
|
||||
jti, err := getTokenJTI(claims["jti"])
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "getTokenJTI")
|
||||
}
|
||||
|
||||
token := &RefreshToken{
|
||||
ISS: issuer,
|
||||
TTL: ttl,
|
||||
EXP: expiry,
|
||||
IAT: issuedAt,
|
||||
SUB: subject,
|
||||
JTI: jti,
|
||||
Scope: scope,
|
||||
}
|
||||
|
||||
valid, err := CheckTokenNotRevoked(ctx, tx, token)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "CheckTokenNotRevoked")
|
||||
}
|
||||
if !valid {
|
||||
return nil, errors.New("Token has been revoked")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Parse a token, validating its signing sigature and returning the claims
|
||||
func parseToken(secretKey string, tokenString string) (jwt.MapClaims, error) {
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
return []byte(secretKey), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "jwt.Parse")
|
||||
}
|
||||
// Token decoded, parse the claims
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, errors.New("Failed to parse claims")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// Check if a token is expired. Returns the expiry if not expired
|
||||
func checkTokenExpired(expiry interface{}) (int64, error) {
|
||||
// Coerce the expiry to a float64 to avoid scientific notation
|
||||
expFloat, ok := expiry.(float64)
|
||||
if !ok {
|
||||
return 0, errors.New("Missing or invalid 'exp' claim")
|
||||
}
|
||||
// Convert to the int64 time we expect :)
|
||||
expiryTime := int64(expFloat)
|
||||
|
||||
// Check if its expired
|
||||
isExpired := time.Now().After(time.Unix(expiryTime, 0))
|
||||
if isExpired {
|
||||
return 0, errors.New("Token has expired")
|
||||
}
|
||||
return expiryTime, nil
|
||||
}
|
||||
|
||||
// Check if a token has a valid issuer. Returns the issuer if valid
|
||||
func checkTokenIssuer(trustedHost string, issuer interface{}) (string, error) {
|
||||
issuerVal, ok := issuer.(string)
|
||||
if !ok {
|
||||
return "", errors.New("Missing or invalid 'iss' claim")
|
||||
}
|
||||
if issuer != trustedHost {
|
||||
return "", errors.New("Issuer does not matched trusted host")
|
||||
}
|
||||
return issuerVal, nil
|
||||
}
|
||||
|
||||
// Check the scope matches the expected scope. Returns scope if true
|
||||
func getTokenScope(scope interface{}) (string, error) {
|
||||
scopeStr, ok := scope.(string)
|
||||
if !ok {
|
||||
return "", errors.New("Missing or invalid 'scope' claim")
|
||||
}
|
||||
return scopeStr, nil
|
||||
}
|
||||
|
||||
// Get the TTL of the token, either "session" or "exp"
|
||||
func getTokenTTL(ttl interface{}) (string, error) {
|
||||
ttlStr, ok := ttl.(string)
|
||||
if !ok {
|
||||
return "", errors.New("Missing or invalid 'ttl' claim")
|
||||
}
|
||||
if ttlStr != "exp" && ttlStr != "session" {
|
||||
return "", errors.New("TTL value is not recognised")
|
||||
}
|
||||
return ttlStr, nil
|
||||
}
|
||||
|
||||
// Get the time the token was issued at
|
||||
func getIssuedTime(issued interface{}) (int64, error) {
|
||||
// Same float64 -> int64 trick as expiry
|
||||
issuedFloat, ok := issued.(float64)
|
||||
if !ok {
|
||||
return 0, errors.New("Missing or invalid 'iat' claim")
|
||||
}
|
||||
issuedAt := int64(issuedFloat)
|
||||
return issuedAt, nil
|
||||
}
|
||||
|
||||
// Get the freshness expiry timestamp
|
||||
func getFreshTime(fresh interface{}) (int64, error) {
|
||||
freshUntil, ok := fresh.(float64)
|
||||
if !ok {
|
||||
return 0, errors.New("Missing or invalid 'fresh' claim")
|
||||
}
|
||||
return int64(freshUntil), nil
|
||||
}
|
||||
|
||||
// Get the subject of the token
|
||||
func getTokenSubject(sub interface{}) (int, error) {
|
||||
subject, ok := sub.(float64)
|
||||
if !ok {
|
||||
return 0, errors.New("Missing or invalid 'sub' claim")
|
||||
}
|
||||
return int(subject), nil
|
||||
}
|
||||
|
||||
// Get the JTI of the token
|
||||
func getTokenJTI(jti interface{}) (uuid.UUID, error) {
|
||||
jtiStr, ok := jti.(string)
|
||||
if !ok {
|
||||
return uuid.UUID{}, errors.New("Missing or invalid 'jti' claim")
|
||||
}
|
||||
jtiUUID, err := uuid.Parse(jtiStr)
|
||||
if err != nil {
|
||||
return uuid.UUID{}, errors.New("JTI is not a valid UUID")
|
||||
}
|
||||
return jtiUUID, nil
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"projectreshoot/pkg/db"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Revoke a token by adding it to the database
|
||||
func RevokeToken(ctx context.Context, tx *db.SafeWTX, t Token) error {
|
||||
jti := t.GetJTI()
|
||||
exp := t.GetEXP()
|
||||
query := `INSERT INTO jwtblacklist (jti, exp) VALUES (?, ?)`
|
||||
_, err := tx.Exec(ctx, query, jti, exp)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "tx.Exec")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if a token has been revoked. Returns true if not revoked.
|
||||
func CheckTokenNotRevoked(ctx context.Context, tx db.SafeTX, t Token) (bool, error) {
|
||||
jti := t.GetJTI()
|
||||
query := `SELECT 1 FROM jwtblacklist WHERE jti = ? LIMIT 1`
|
||||
rows, err := tx.Query(ctx, query, jti)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "tx.Query")
|
||||
}
|
||||
defer rows.Close()
|
||||
revoked := rows.Next()
|
||||
return !revoked, nil
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"projectreshoot/internal/models"
|
||||
"projectreshoot/pkg/db"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type Token interface {
|
||||
GetJTI() uuid.UUID
|
||||
GetEXP() int64
|
||||
GetScope() string
|
||||
GetUser(ctx context.Context, tx db.SafeTX) (*models.User, error)
|
||||
}
|
||||
|
||||
// Access token
|
||||
type AccessToken struct {
|
||||
ISS string // Issuer, generally TrustedHost
|
||||
IAT int64 // Time issued at
|
||||
EXP int64 // Time expiring at
|
||||
TTL string // Time-to-live: "session" or "exp". Used with 'remember me'
|
||||
SUB int // Subject (user) ID
|
||||
JTI uuid.UUID // UUID-4 used for identifying blacklisted tokens
|
||||
Fresh int64 // Time freshness expiring at
|
||||
Scope string // Should be "access"
|
||||
}
|
||||
|
||||
// Refresh token
|
||||
type RefreshToken struct {
|
||||
ISS string // Issuer, generally TrustedHost
|
||||
IAT int64 // Time issued at
|
||||
EXP int64 // Time expiring at
|
||||
TTL string // Time-to-live: "session" or "exp". Used with 'remember me'
|
||||
SUB int // Subject (user) ID
|
||||
JTI uuid.UUID // UUID-4 used for identifying blacklisted tokens
|
||||
Scope string // Should be "refresh"
|
||||
}
|
||||
|
||||
func (a AccessToken) GetUser(ctx context.Context, tx db.SafeTX) (*models.User, error) {
|
||||
user, err := models.GetUserFromID(ctx, tx, a.SUB)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "db.GetUserFromID")
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
func (r RefreshToken) GetUser(ctx context.Context, tx db.SafeTX) (*models.User, error) {
|
||||
user, err := models.GetUserFromID(ctx, tx, r.SUB)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "db.GetUserFromID")
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (a AccessToken) GetJTI() uuid.UUID {
|
||||
return a.JTI
|
||||
}
|
||||
func (r RefreshToken) GetJTI() uuid.UUID {
|
||||
return r.JTI
|
||||
}
|
||||
func (a AccessToken) GetEXP() int64 {
|
||||
return a.EXP
|
||||
}
|
||||
func (r RefreshToken) GetEXP() int64 {
|
||||
return r.EXP
|
||||
}
|
||||
func (a AccessToken) GetScope() string {
|
||||
return a.Scope
|
||||
}
|
||||
func (r RefreshToken) GetScope() string {
|
||||
return r.Scope
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"os"
|
||||
"projectreshoot/pkg/config"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func TestConfig() (*config.Config, error) {
|
||||
os.Setenv("SECRET_KEY", ".")
|
||||
os.Setenv("TMDB_API_TOKEN", ".")
|
||||
cfg, err := config.GetConfig(map[string]string{})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "config.GetConfig")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/pressly/goose/v3"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func findMigrations() (*fs.FS, error) {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, "Makefile")); err == nil {
|
||||
migrationsdir := os.DirFS(filepath.Join(dir, "cmd", "migrate", "migrations"))
|
||||
return &migrationsdir, nil
|
||||
}
|
||||
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir { // Reached root
|
||||
return nil, errors.New("Unable to locate migrations directory")
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
func findTestData() (string, error) {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, "Makefile")); err == nil {
|
||||
return filepath.Join(dir, "pkg", "tests", "testdata.sql"), nil
|
||||
}
|
||||
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir { // Reached root
|
||||
return "", errors.New("Unable to locate test data")
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
func migrateTestDB(wconn *sql.DB, version int64) error {
|
||||
migrations, err := findMigrations()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "findMigrations")
|
||||
}
|
||||
provider, err := goose.NewProvider(goose.DialectSQLite3, wconn, *migrations)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "goose.NewProvider")
|
||||
}
|
||||
ctx := context.Background()
|
||||
if _, err := provider.UpTo(ctx, version); err != nil {
|
||||
return errors.Wrap(err, "provider.UpTo")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadTestData(wconn *sql.DB) error {
|
||||
dataPath, err := findTestData()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "findSchema")
|
||||
}
|
||||
sqlBytes, err := os.ReadFile(dataPath)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "os.ReadFile")
|
||||
}
|
||||
dataSQL := string(sqlBytes)
|
||||
|
||||
_, err = wconn.Exec(dataSQL)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "tx.Exec")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Returns two db connection handles. First is a readwrite connection, second
|
||||
// is a read only connection
|
||||
func SetupTestDB(version int64) (*sql.DB, *sql.DB, error) {
|
||||
opts := "_journal_mode=WAL&_synchronous=NORMAL&_txlock=IMMEDIATE"
|
||||
file := fmt.Sprintf("file::memory:?cache=shared&%s", opts)
|
||||
wconn, err := sql.Open("sqlite", file)
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrap(err, "sql.Open")
|
||||
}
|
||||
|
||||
err = migrateTestDB(wconn, version)
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrap(err, "migrateTestDB")
|
||||
}
|
||||
err = loadTestData(wconn)
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrap(err, "loadTestData")
|
||||
}
|
||||
|
||||
opts = "_synchronous=NORMAL&mode=ro"
|
||||
file = fmt.Sprintf("file::memory:?cache=shared&%s", opts)
|
||||
rconn, err := sql.Open("sqlite", file)
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrap(err, "sql.Open")
|
||||
}
|
||||
return wconn, rconn, nil
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type TLogWriter struct {
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
// Write implements the io.Writer interface for TLogWriter.
|
||||
func (w *TLogWriter) Write(p []byte) (n int, err error) {
|
||||
w.t.Logf("%s", p)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Return a fake logger to satisfy functions that expect one
|
||||
func NilLogger() *zerolog.Logger {
|
||||
logger := zerolog.New(nil)
|
||||
return &logger
|
||||
}
|
||||
|
||||
// Return a logger that makes use of the T.Log method to enable debugging tests
|
||||
func DebugLogger(t *testing.T) *zerolog.Logger {
|
||||
logger := zerolog.New(GetTLogWriter(t))
|
||||
return &logger
|
||||
}
|
||||
|
||||
func GetTLogWriter(t *testing.T) *TLogWriter {
|
||||
return &TLogWriter{t: t}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
INSERT INTO users VALUES(1,'testuser','hashedpassword',1738995274, 'bio');
|
||||
INSERT INTO jwtblacklist VALUES('0a6b338e-930a-43fe-8f70-1a6daed256fa', 33299675344);
|
||||
INSERT INTO jwtblacklist VALUES('b7fa51dc-8532-42e1-8756-5d25bfb2003a', 33299675344);
|
||||
@@ -1,32 +0,0 @@
|
||||
package tmdb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Image Image `json:"images"`
|
||||
}
|
||||
|
||||
type Image struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
SecureBaseURL string `json:"secure_base_url"`
|
||||
BackdropSizes []string `json:"backdrop_sizes"`
|
||||
LogoSizes []string `json:"logo_sizes"`
|
||||
PosterSizes []string `json:"poster_sizes"`
|
||||
ProfileSizes []string `json:"profile_sizes"`
|
||||
StillSizes []string `json:"still_sizes"`
|
||||
}
|
||||
|
||||
func GetConfig(token string) (*Config, error) {
|
||||
url := "https://api.themoviedb.org/3/configuration"
|
||||
data, err := tmdbGet(url, token)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tmdbGet")
|
||||
}
|
||||
config := Config{}
|
||||
json.Unmarshal(data, &config)
|
||||
return &config, nil
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package tmdb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type Credits struct {
|
||||
ID int32 `json:"id"`
|
||||
Cast []Cast `json:"cast"`
|
||||
Crew []Crew `json:"crew"`
|
||||
}
|
||||
|
||||
type Cast struct {
|
||||
Adult bool `json:"adult"`
|
||||
Gender int `json:"gender"`
|
||||
ID int32 `json:"id"`
|
||||
KnownFor string `json:"known_for_department"`
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Popularity int `json:"popularity"`
|
||||
Profile string `json:"profile_path"`
|
||||
CastID int32 `json:"cast_id"`
|
||||
Character string `json:"character"`
|
||||
CreditID string `json:"credit_id"`
|
||||
Order int `json:"order"`
|
||||
}
|
||||
|
||||
type Crew struct {
|
||||
Adult bool `json:"adult"`
|
||||
Gender int `json:"gender"`
|
||||
ID int32 `json:"id"`
|
||||
KnownFor string `json:"known_for_department"`
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Popularity int `json:"popularity"`
|
||||
Profile string `json:"profile_path"`
|
||||
CreditID string `json:"credit_id"`
|
||||
Department string `json:"department"`
|
||||
Job string `json:"job"`
|
||||
}
|
||||
|
||||
func GetCredits(movieid int32, token string) (*Credits, error) {
|
||||
url := fmt.Sprintf("https://api.themoviedb.org/3/movie/%v/credits?language=en-US", movieid)
|
||||
data, err := tmdbGet(url, token)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tmdbGet")
|
||||
}
|
||||
credits := Credits{}
|
||||
json.Unmarshal(data, &credits)
|
||||
return &credits, nil
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package tmdb
|
||||
|
||||
import "sort"
|
||||
|
||||
type BilledCrew struct {
|
||||
Name string
|
||||
Roles []string
|
||||
}
|
||||
|
||||
func (credits *Credits) BilledCrew() []BilledCrew {
|
||||
crewmap := make(map[string][]string)
|
||||
billedcrew := []BilledCrew{}
|
||||
for _, crew := range credits.Crew {
|
||||
if crew.Job == "Director" ||
|
||||
crew.Job == "Screenplay" ||
|
||||
crew.Job == "Writer" ||
|
||||
crew.Job == "Novel" ||
|
||||
crew.Job == "Story" {
|
||||
crewmap[crew.Name] = append(crewmap[crew.Name], crew.Job)
|
||||
}
|
||||
}
|
||||
|
||||
for name, jobs := range crewmap {
|
||||
billedcrew = append(billedcrew, BilledCrew{Name: name, Roles: jobs})
|
||||
}
|
||||
for i := range billedcrew {
|
||||
sort.Strings(billedcrew[i].Roles)
|
||||
}
|
||||
sort.Slice(billedcrew, func(i, j int) bool {
|
||||
return billedcrew[i].Roles[0] < billedcrew[j].Roles[0]
|
||||
})
|
||||
return billedcrew
|
||||
}
|
||||
|
||||
func (billedcrew *BilledCrew) FRoles() string {
|
||||
jobs := ""
|
||||
for _, job := range billedcrew.Roles {
|
||||
jobs += job + ", "
|
||||
}
|
||||
return jobs[:len(jobs)-2]
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package tmdb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type Movie struct {
|
||||
Adult bool `json:"adult"`
|
||||
Backdrop string `json:"backdrop_path"`
|
||||
Collection string `json:"belongs_to_collection"`
|
||||
Budget int `json:"budget"`
|
||||
Genres []Genre `json:"genres"`
|
||||
Homepage string `json:"homepage"`
|
||||
ID int32 `json:"id"`
|
||||
IMDbID string `json:"imdb_id"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float32 `json:"popularity"`
|
||||
Poster string `json:"poster_path"`
|
||||
ProductionCompanies []ProductionCompany `json:"production_companies"`
|
||||
ProductionCountries []ProductionCountry `json:"production_countries"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Revenue int `json:"revenue"`
|
||||
Runtime int `json:"runtime"`
|
||||
SpokenLanguages []SpokenLanguage `json:"spoken_languages"`
|
||||
Status string `json:"status"`
|
||||
Tagline string `json:"tagline"`
|
||||
Title string `json:"title"`
|
||||
Video bool `json:"video"`
|
||||
}
|
||||
|
||||
func GetMovie(id int32, token string) (*Movie, error) {
|
||||
url := fmt.Sprintf("https://api.themoviedb.org/3/movie/%v?language=en-US", id)
|
||||
data, err := tmdbGet(url, token)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tmdbGet")
|
||||
}
|
||||
movie := Movie{}
|
||||
json.Unmarshal(data, &movie)
|
||||
return &movie, nil
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package tmdb
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
)
|
||||
|
||||
func (movie *Movie) FRuntime() string {
|
||||
hours := movie.Runtime / 60
|
||||
mins := movie.Runtime % 60
|
||||
return fmt.Sprintf("%dh %02dm", hours, mins)
|
||||
}
|
||||
|
||||
func (movie *Movie) GetPoster(image *Image, size string) string {
|
||||
base, err := url.Parse(image.SecureBaseURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
fullPath := path.Join(base.Path, size, movie.Poster)
|
||||
base.Path = fullPath
|
||||
return base.String()
|
||||
}
|
||||
|
||||
func (movie *Movie) ReleaseYear() string {
|
||||
if movie.ReleaseDate == "" {
|
||||
return ""
|
||||
} else {
|
||||
return "(" + movie.ReleaseDate[:4] + ")"
|
||||
}
|
||||
}
|
||||
|
||||
func (movie *Movie) FGenres() string {
|
||||
genres := ""
|
||||
for _, genre := range movie.Genres {
|
||||
genres += genre.Name + ", "
|
||||
}
|
||||
if len(genres) > 2 {
|
||||
return genres[:len(genres)-2]
|
||||
}
|
||||
return genres
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package tmdb
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func tmdbGet(url string, token string) ([]byte, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "http.NewRequest")
|
||||
}
|
||||
req.Header.Add("accept", "application/json")
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "http.DefaultClient.Do")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "io.ReadAll")
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package tmdb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
Page int `json:"page"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
type ResultMovies struct {
|
||||
Result
|
||||
Results []ResultMovie `json:"results"`
|
||||
}
|
||||
type ResultMovie struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIDs []int `json:"genre_ids"`
|
||||
ID int32 `json:"id"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity int `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Title string `json:"title"`
|
||||
Video bool `json:"video"`
|
||||
VoteAverage int `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
}
|
||||
|
||||
func (movie *ResultMovie) GetPoster(image *Image, size string) string {
|
||||
base, err := url.Parse(image.SecureBaseURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
fullPath := path.Join(base.Path, size, movie.PosterPath)
|
||||
base.Path = fullPath
|
||||
return base.String()
|
||||
}
|
||||
|
||||
func (movie *ResultMovie) ReleaseYear() string {
|
||||
if movie.ReleaseDate == "" {
|
||||
return ""
|
||||
} else {
|
||||
return "(" + movie.ReleaseDate[:4] + ")"
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: genres list https://developer.themoviedb.org/reference/genre-movie-list
|
||||
// func (movie *ResultMovie) FGenres() string {
|
||||
// genres := ""
|
||||
// for _, genre := range movie.Genres {
|
||||
// genres += genre.Name + ", "
|
||||
// }
|
||||
// return genres[:len(genres)-2]
|
||||
// }
|
||||
|
||||
func SearchMovies(token string, query string, adult bool, page int) (*ResultMovies, error) {
|
||||
url := "https://api.themoviedb.org/3/search/movie" +
|
||||
fmt.Sprintf("?query=%s", url.QueryEscape(query)) +
|
||||
fmt.Sprintf("&include_adult=%t", adult) +
|
||||
fmt.Sprintf("&page=%v", page) +
|
||||
"&language=en-US"
|
||||
response, err := tmdbGet(url, token)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tmdbGet")
|
||||
}
|
||||
var results ResultMovies
|
||||
json.Unmarshal(response, &results)
|
||||
return &results, nil
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package tmdb
|
||||
|
||||
type Genre struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type ProductionCompany struct {
|
||||
ID int `json:"id"`
|
||||
Logo string `json:"logo_path"`
|
||||
Name string `json:"name"`
|
||||
OriginCountry string `json:"origin_country"`
|
||||
}
|
||||
|
||||
type ProductionCountry struct {
|
||||
ISO_3166_1 string `json:"iso_3166_1"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type SpokenLanguage struct {
|
||||
EnglishName string `json:"english_name"`
|
||||
ISO_639_1 string `json:"iso_639_1"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
Reference in New Issue
Block a user