add working signup + login
This commit is contained in:
		
							parent
							
								
									bc85b7c340
								
							
						
					
					
						commit
						d8cb8c8fa8
					
				
					 27 changed files with 600 additions and 39 deletions
				
			
		
							
								
								
									
										30
									
								
								internal/concurrent/value.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								internal/concurrent/value.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,30 @@ | |||
| package concurrent | ||||
| 
 | ||||
| import "sync" | ||||
| 
 | ||||
| // Value is a struct providing concurrent access to another type. | ||||
| // Note: T should be a concrete type, not a pointer. | ||||
| type Value[T any] struct { | ||||
| 	val T | ||||
| 	mu  sync.RWMutex | ||||
| } | ||||
| 
 | ||||
| func NewValue[T any](value T) *Value[T] { | ||||
| 	return &Value[T]{ | ||||
| 		val: value, | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (v *Value[T]) Get() T { | ||||
| 	v.mu.RLock() | ||||
| 	val := v.val | ||||
| 	v.mu.RUnlock() | ||||
| 
 | ||||
| 	return val | ||||
| } | ||||
| 
 | ||||
| func (v *Value[T]) Set(new T) { | ||||
| 	v.mu.Lock() | ||||
| 	v.val = new | ||||
| 	v.mu.Unlock() | ||||
| } | ||||
|  | @ -3,8 +3,12 @@ package database | |||
| import "github.com/oklog/ulid/v2" | ||||
| 
 | ||||
| type Config struct { | ||||
| 	Name    string | ||||
| 	AdminID *ulid.ULID | ||||
| 	Name                string | ||||
| 	AdminID             *ulid.ULID | ||||
| 	InternalApplication *ulid.ULID | ||||
| 
 | ||||
| 	// ID is always 1 | ||||
| 	ID int | ||||
| } | ||||
| 
 | ||||
| var DefaultConfig = Config{ | ||||
|  |  | |||
|  | @ -38,7 +38,31 @@ create table posts ( | |||
|     visibility post_visibility not null | ||||
| ); | ||||
| 
 | ||||
| create table applications ( | ||||
|     id text primary key, | ||||
|     name text not null | ||||
| ); | ||||
| 
 | ||||
| create table tokens ( | ||||
|     id      text primary key, | ||||
|     app_id  text not null references applications (id) on delete cascade, | ||||
|     user_id text not null references accounts (id) on delete cascade, | ||||
|     scopes  text[] not null default array[]::text[], | ||||
|     expires timestamptz not null | ||||
| ); | ||||
| 
 | ||||
| create table config ( | ||||
|     id int primary key not null default 1, | ||||
|     name text not null, -- instance name | ||||
|     admin_id text references blogs (id) on delete set null, -- admin contact | ||||
|     internal_application text references applications (id) on delete set null, | ||||
|     constraint singleton check (id = 1) -- only one config table entry | ||||
| ); | ||||
| 
 | ||||
| -- +migrate Down | ||||
| drop table config; | ||||
| drop table tokens; | ||||
| drop table applications; | ||||
| drop table accounts; | ||||
| drop table blogs; | ||||
| drop table posts; | ||||
|  |  | |||
|  | @ -37,8 +37,13 @@ func (s *AccountStore) ByID(ctx context.Context, id ulid.ULID) (a database.Accou | |||
| } | ||||
| 
 | ||||
| // ByUsername gets an account by its username. | ||||
| func (s *AccountStore) ByUsername(ctx context.Context, username string, host *string) (a database.Account, err error) { | ||||
| 	q := sqlf.Sprintf("SELECT * FROM accounts WHERE username = %s AND host = %v", username, host) | ||||
| func (s *AccountStore) ByUsername(ctx context.Context, username, domain string) (a database.Account, err error) { | ||||
| 	q := sqlf.Sprintf("SELECT * FROM accounts WHERE username = %s", username) | ||||
| 	if domain == "" { | ||||
| 		q = sqlf.Sprintf("%v AND domain IS NULL", q) | ||||
| 	} else { | ||||
| 		q = sqlf.Sprintf("%v AND domain = %s", q, domain) | ||||
| 	} | ||||
| 
 | ||||
| 	a, err = Get[database.Account](ctx, s.q, q) | ||||
| 	if err != nil { | ||||
|  | @ -64,7 +69,7 @@ func (s *AccountStore) CreateLocal( | |||
| 	} | ||||
| 
 | ||||
| 	q := sqlf.Sprintf( | ||||
| 		"INSERT INTO accounts (id, username, host, email, password) VALUES (%s, %v, NULL, %v, %v) RETURNING *", | ||||
| 		"INSERT INTO accounts (id, username, domain, email, password) VALUES (%s, %v, NULL, %v, %v) RETURNING *", | ||||
| 		makeULID(), username, email, hash) | ||||
| 
 | ||||
| 	a, err = Get[database.Account](ctx, s.q, q) | ||||
|  |  | |||
							
								
								
									
										51
									
								
								internal/database/sql/config.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								internal/database/sql/config.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,51 @@ | |||
| package sql | ||||
| 
 | ||||
| import ( | ||||
| 	"context" | ||||
| 
 | ||||
| 	"emperror.dev/errors" | ||||
| 	"git.sleepycat.moe/sam/mercury/internal/database" | ||||
| 	"github.com/keegancsmith/sqlf" | ||||
| ) | ||||
| 
 | ||||
| // ConfigStore is the interface to configs in the database. | ||||
| type ConfigStore struct { | ||||
| 	q Querier | ||||
| } | ||||
| 
 | ||||
| // NewConfigStore creates a new ConfigStore instance. | ||||
| func NewConfigStore(q Querier) *ConfigStore { | ||||
| 	return &ConfigStore{q: q} | ||||
| } | ||||
| 
 | ||||
| func (s *ConfigStore) initConfig(ctx context.Context) error { | ||||
| 	q := sqlf.Sprintf(`INSERT INTO config | ||||
| 	(id, name) | ||||
| 	VALUES (1, %s) | ||||
| 	ON CONFLICT (id) DO NOTHING`, database.DefaultConfig.Name) | ||||
| 
 | ||||
| 	err := Exec(ctx, s.q, q) | ||||
| 	return errors.Wrap(err, "executing query") | ||||
| } | ||||
| 
 | ||||
| func (s *ConfigStore) Get(ctx context.Context) (database.Config, error) { | ||||
| 	q := sqlf.Sprintf("SELECT * FROM config WHERE id = 1") | ||||
| 
 | ||||
| 	return Get[database.Config](ctx, s.q, q) | ||||
| } | ||||
| 
 | ||||
| func (s *ConfigStore) Set(ctx context.Context, cur, new database.Config) (database.Config, error) { | ||||
| 	q := sqlf.Sprintf("UPDATE config SET") | ||||
| 	if cur.Name != new.Name { | ||||
| 		q = sqlf.Sprintf("%v name = %v,", q, new.Name) | ||||
| 	} | ||||
| 	if cur.AdminID != new.AdminID { | ||||
| 		q = sqlf.Sprintf("%v admin_id = %v,", q, new.AdminID) | ||||
| 	} | ||||
| 	if cur.InternalApplication != new.InternalApplication { | ||||
| 		q = sqlf.Sprintf("%v internal_application = %v,", q, new.InternalApplication) | ||||
| 	} | ||||
| 	q = sqlf.Sprintf("%v id = %v WHERE id = %v RETURNING *", q, cur.ID, cur.ID) | ||||
| 
 | ||||
| 	return Get[database.Config](ctx, s.q, q) | ||||
| } | ||||
|  | @ -5,6 +5,7 @@ import ( | |||
| 	"context" | ||||
| 
 | ||||
| 	"emperror.dev/errors" | ||||
| 	"git.sleepycat.moe/sam/mercury/internal/database" | ||||
| 	"github.com/jackc/pgx/v5/pgxpool" | ||||
| 	"github.com/oklog/ulid/v2" | ||||
| ) | ||||
|  | @ -24,9 +25,55 @@ func NewBase(ctx context.Context, connString string) (*Base, error) { | |||
| 	base := &Base{ | ||||
| 		pool: pool, | ||||
| 	} | ||||
| 
 | ||||
| 	// create configuration | ||||
| 	if err := base.initSingletons(ctx); err != nil { | ||||
| 		return nil, errors.Wrap(err, "initializing configuration") | ||||
| 	} | ||||
| 
 | ||||
| 	return base, nil | ||||
| } | ||||
| 
 | ||||
| func (base *Base) initSingletons(ctx context.Context) error { | ||||
| 	err := NewConfigStore(base.pool).initConfig(ctx) | ||||
| 	if err != nil { | ||||
| 		return errors.Wrap(err, "initializing configuration") | ||||
| 	} | ||||
| 
 | ||||
| 	cfg, err := NewConfigStore(base.pool).Get(ctx) | ||||
| 	if err != nil { | ||||
| 		return errors.Wrap(err, "getting configuration") | ||||
| 	} | ||||
| 
 | ||||
| 	if cfg.InternalApplication == nil { | ||||
| 		tx, err := base.BeginTx(ctx) | ||||
| 		if err != nil { | ||||
| 			return errors.Wrap(err, "creating transaction") | ||||
| 		} | ||||
| 		defer tx.Rollback(ctx) | ||||
| 
 | ||||
| 		app, err := NewTokenStore(tx).CreateApplication(ctx, database.InternalApplicationName) | ||||
| 		if err != nil { | ||||
| 			return errors.Wrap(err, "creating internal application") | ||||
| 		} | ||||
| 
 | ||||
| 		newCfg := cfg | ||||
| 		newCfg.InternalApplication = &app.ID | ||||
| 
 | ||||
| 		cfg, err = NewConfigStore(tx).Set(ctx, cfg, newCfg) | ||||
| 		if err != nil { | ||||
| 			return errors.Wrap(err, "updating configuration") | ||||
| 		} | ||||
| 
 | ||||
| 		err = tx.Commit(ctx) | ||||
| 		if err != nil { | ||||
| 			return errors.Wrap(err, "committing transaction") | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| // Acquire acquires a connection from the database pool. | ||||
| // It is the caller's responsibility to call the Release method. | ||||
| func (base *Base) Acquire(ctx context.Context) (ReleaseableQuerier, error) { | ||||
|  |  | |||
|  | @ -48,3 +48,8 @@ func Get[T any](ctx context.Context, querier Querier, query *sqlf.Query) (T, err | |||
| 	} | ||||
| 	return dst, nil | ||||
| } | ||||
| 
 | ||||
| func Exec(ctx context.Context, querier Querier, query *sqlf.Query) error { | ||||
| 	_, err := querier.Exec(ctx, query.Query(sqlf.PostgresBindVar), query.Args()...) | ||||
| 	return err | ||||
| } | ||||
|  |  | |||
							
								
								
									
										52
									
								
								internal/database/sql/token.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								internal/database/sql/token.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,52 @@ | |||
| package sql | ||||
| 
 | ||||
| import ( | ||||
| 	"context" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"emperror.dev/errors" | ||||
| 	"git.sleepycat.moe/sam/mercury/internal/database" | ||||
| 	"github.com/keegancsmith/sqlf" | ||||
| 	"github.com/oklog/ulid/v2" | ||||
| ) | ||||
| 
 | ||||
| // TokenStore is the interface to tokens in the database. | ||||
| type TokenStore struct { | ||||
| 	q Querier | ||||
| } | ||||
| 
 | ||||
| // NewTokenStore creates a new TokenStore instance. | ||||
| func NewTokenStore(q Querier) *TokenStore { | ||||
| 	return &TokenStore{q: q} | ||||
| } | ||||
| 
 | ||||
| func (s *TokenStore) Get(ctx context.Context, id ulid.ULID) (database.Token, error) { | ||||
| 	q := sqlf.Sprintf("SELECT * FROM tokens WHERE id = %s", id) | ||||
| 
 | ||||
| 	t, err := Get[database.Token](ctx, s.q, q) | ||||
| 	return t, errors.Wrap(err, "executing query") | ||||
| } | ||||
| 
 | ||||
| func (s *TokenStore) GetApplication(ctx context.Context, id ulid.ULID) (database.Application, error) { | ||||
| 	q := sqlf.Sprintf("SELECT * FROM applications WHERE id = %s", id) | ||||
| 
 | ||||
| 	app, err := Get[database.Application](ctx, s.q, q) | ||||
| 	return app, errors.Wrap(err, "executing query") | ||||
| } | ||||
| 
 | ||||
| func (s *TokenStore) Create(ctx context.Context, userID, appID ulid.ULID, scopes []string, expires time.Time) (database.Token, error) { | ||||
| 	q := sqlf.Sprintf(`INSERT INTO tokens | ||||
| 	(id, user_id, app_id, scopes, expires) | ||||
| 	values (%s, %s, %s, %v, %v) | ||||
| 	RETURNING *`, makeULID(), userID, appID, scopes, expires) | ||||
| 
 | ||||
| 	t, err := Get[database.Token](ctx, s.q, q) | ||||
| 	return t, errors.Wrap(err, "executing query") | ||||
| } | ||||
| 
 | ||||
| func (s *TokenStore) CreateApplication(ctx context.Context, name string) (database.Application, error) { | ||||
| 	q := sqlf.Sprintf("INSERT INTO applications (id, name) VALUES (%s, %s) RETURNING *", makeULID(), name) | ||||
| 
 | ||||
| 	app, err := Get[database.Application](ctx, s.q, q) | ||||
| 	return app, errors.Wrap(err, "executing query") | ||||
| } | ||||
							
								
								
									
										45
									
								
								internal/database/token.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								internal/database/token.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,45 @@ | |||
| package database | ||||
| 
 | ||||
| import ( | ||||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/golang-jwt/jwt/v4" | ||||
| 	"github.com/oklog/ulid/v2" | ||||
| ) | ||||
| 
 | ||||
| const InternalApplicationName = "mercury_internal" | ||||
| const TokenCookieName = "mercury_token" | ||||
| 
 | ||||
| type Application struct { | ||||
| 	ID   ulid.ULID | ||||
| 	Name string | ||||
| } | ||||
| 
 | ||||
| type Token struct { | ||||
| 	ID      ulid.ULID | ||||
| 	AppID   ulid.ULID | ||||
| 	UserID  ulid.ULID | ||||
| 	Scopes  []string | ||||
| 	Expires time.Time | ||||
| } | ||||
| 
 | ||||
| func (t Token) ToClaims() TokenClaims { | ||||
| 	createdAt := ulid.Time(t.ID.Time()) | ||||
| 
 | ||||
| 	return TokenClaims{ | ||||
| 		TokenID: t.ID, | ||||
| 		UserID:  t.UserID, | ||||
| 		RegisteredClaims: jwt.RegisteredClaims{ | ||||
| 			Issuer:    "mercury", | ||||
| 			ExpiresAt: jwt.NewNumericDate(t.Expires), | ||||
| 			IssuedAt:  jwt.NewNumericDate(createdAt), | ||||
| 			NotBefore: jwt.NewNumericDate(createdAt), | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| type TokenClaims struct { | ||||
| 	TokenID ulid.ULID `json:"jti"` | ||||
| 	UserID  ulid.ULID `json:"sub"` | ||||
| 	jwt.RegisteredClaims | ||||
| } | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue