init
This commit is contained in:
commit
b15b95781c
108 changed files with 14802 additions and 0 deletions
761
internal/db/apikeys.go
Normal file
761
internal/db/apikeys.go
Normal file
|
|
@ -0,0 +1,761 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CreateAPIKeyParams struct {
|
||||
Name string
|
||||
KeyPrefix string
|
||||
KeyHash string
|
||||
Description string
|
||||
ScopeMode ScopeMode
|
||||
CanDownload bool
|
||||
CanUpload bool
|
||||
CanDelete bool
|
||||
CanManageProjects bool
|
||||
IsActive bool
|
||||
ExpiresAt *time.Time
|
||||
CreatedByUserID *int64
|
||||
}
|
||||
|
||||
type UpdateAPIKeyParams struct {
|
||||
Name string
|
||||
Description string
|
||||
ScopeMode ScopeMode
|
||||
CanDownload bool
|
||||
CanUpload bool
|
||||
CanDelete bool
|
||||
CanManageProjects bool
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) List(ctx context.Context) ([]APIKeyListItem, error) {
|
||||
rows, err := r.q.QueryContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
ak.id,
|
||||
ak.name,
|
||||
ak.key_prefix,
|
||||
ak.key_hash,
|
||||
ak.description,
|
||||
ak.scope_mode,
|
||||
ak.can_download,
|
||||
ak.can_upload,
|
||||
ak.can_delete,
|
||||
ak.can_manage_projects,
|
||||
ak.is_active,
|
||||
ak.expires_at,
|
||||
ak.created_at,
|
||||
ak.updated_at,
|
||||
ak.last_used_at,
|
||||
ak.created_by_user_id,
|
||||
COUNT(DISTINCT ap.project_id) AS project_rule_count,
|
||||
COUNT(DISTINCT at.tag_id) AS tag_rule_count
|
||||
FROM api_keys AS ak
|
||||
LEFT JOIN api_key_project_access AS ap ON ap.api_key_id = ak.id
|
||||
LEFT JOIN api_key_tag_access AS at ON at.api_key_id = ak.id
|
||||
GROUP BY ak.id
|
||||
ORDER BY ak.is_active DESC, ak.updated_at DESC, ak.created_at DESC, ak.name COLLATE NOCASE`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query api keys: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]APIKeyListItem, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanAPIKeyListItem(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan api key list item: %w", err)
|
||||
}
|
||||
|
||||
items = append(items, *item)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate api keys: %w", err)
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) GetByID(ctx context.Context, id int64) (*APIKey, error) {
|
||||
key, err := scanAPIKey(r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
id,
|
||||
name,
|
||||
key_prefix,
|
||||
key_hash,
|
||||
description,
|
||||
scope_mode,
|
||||
can_download,
|
||||
can_upload,
|
||||
can_delete,
|
||||
can_manage_projects,
|
||||
is_active,
|
||||
expires_at,
|
||||
created_at,
|
||||
updated_at,
|
||||
last_used_at,
|
||||
created_by_user_id
|
||||
FROM api_keys
|
||||
WHERE id = ?`,
|
||||
id,
|
||||
))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("scan api key by id: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) GetByHash(ctx context.Context, keyHash string) (*APIKey, error) {
|
||||
key, err := scanAPIKey(r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
id,
|
||||
name,
|
||||
key_prefix,
|
||||
key_hash,
|
||||
description,
|
||||
scope_mode,
|
||||
can_download,
|
||||
can_upload,
|
||||
can_delete,
|
||||
can_manage_projects,
|
||||
is_active,
|
||||
expires_at,
|
||||
created_at,
|
||||
updated_at,
|
||||
last_used_at,
|
||||
created_by_user_id
|
||||
FROM api_keys
|
||||
WHERE key_hash = ?
|
||||
LIMIT 1`,
|
||||
strings.TrimSpace(keyHash),
|
||||
))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("scan api key by hash: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) Create(ctx context.Context, params CreateAPIKeyParams) (*APIKey, error) {
|
||||
isActive := 0
|
||||
if params.IsActive {
|
||||
isActive = 1
|
||||
}
|
||||
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO api_keys (
|
||||
name,
|
||||
key_prefix,
|
||||
key_hash,
|
||||
description,
|
||||
scope_mode,
|
||||
can_download,
|
||||
can_upload,
|
||||
can_delete,
|
||||
can_manage_projects,
|
||||
is_active,
|
||||
expires_at,
|
||||
created_by_user_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
strings.TrimSpace(params.Name),
|
||||
strings.TrimSpace(params.KeyPrefix),
|
||||
strings.TrimSpace(params.KeyHash),
|
||||
strings.TrimSpace(params.Description),
|
||||
params.ScopeMode,
|
||||
boolToInt(params.CanDownload),
|
||||
boolToInt(params.CanUpload),
|
||||
boolToInt(params.CanDelete),
|
||||
boolToInt(params.CanManageProjects),
|
||||
isActive,
|
||||
nullableTimestampValue(params.ExpiresAt),
|
||||
params.CreatedByUserID,
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return nil, errors.Join(ErrConflict, fmt.Errorf("insert api key: %w", err))
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("insert api key: %w", err)
|
||||
}
|
||||
|
||||
apiKeyID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load inserted api key id: %w", err)
|
||||
}
|
||||
|
||||
return r.GetByID(ctx, apiKeyID)
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) Update(ctx context.Context, apiKeyID int64, params UpdateAPIKeyParams) (*APIKey, error) {
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`UPDATE api_keys
|
||||
SET name = ?,
|
||||
description = ?,
|
||||
scope_mode = ?,
|
||||
can_download = ?,
|
||||
can_upload = ?,
|
||||
can_delete = ?,
|
||||
can_manage_projects = ?,
|
||||
expires_at = ?
|
||||
WHERE id = ?`,
|
||||
strings.TrimSpace(params.Name),
|
||||
strings.TrimSpace(params.Description),
|
||||
params.ScopeMode,
|
||||
boolToInt(params.CanDownload),
|
||||
boolToInt(params.CanUpload),
|
||||
boolToInt(params.CanDelete),
|
||||
boolToInt(params.CanManageProjects),
|
||||
nullableTimestampValue(params.ExpiresAt),
|
||||
apiKeyID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update api key: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read updated api key rows: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return r.GetByID(ctx, apiKeyID)
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) SetActive(ctx context.Context, apiKeyID int64, isActive bool) error {
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`UPDATE api_keys SET is_active = ? WHERE id = ?`,
|
||||
boolToInt(isActive),
|
||||
apiKeyID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update api key active state: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read updated api key rows: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) TouchLastUsedAt(ctx context.Context, apiKeyID int64, usedAt time.Time) error {
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`UPDATE api_keys SET last_used_at = ? WHERE id = ?`,
|
||||
formatTimestamp(usedAt),
|
||||
apiKeyID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update api key last_used_at: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read updated api key rows: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) ListProjectAccess(ctx context.Context, apiKeyID int64) ([]Project, error) {
|
||||
rows, err := r.q.QueryContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
p.id,
|
||||
p.name,
|
||||
p.slug,
|
||||
p.description,
|
||||
p.is_active,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM projects AS p
|
||||
INNER JOIN api_key_project_access AS ap ON ap.project_id = p.id
|
||||
WHERE ap.api_key_id = ?
|
||||
ORDER BY p.name COLLATE NOCASE`,
|
||||
apiKeyID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query api key project access: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
projects := make([]Project, 0)
|
||||
for rows.Next() {
|
||||
project, err := scanProject(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan api key project access: %w", err)
|
||||
}
|
||||
|
||||
projects = append(projects, *project)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate api key project access: %w", err)
|
||||
}
|
||||
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) ListTagAccess(ctx context.Context, apiKeyID int64) ([]Tag, error) {
|
||||
rows, err := r.q.QueryContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.slug,
|
||||
t.description,
|
||||
t.created_at,
|
||||
t.updated_at
|
||||
FROM tags AS t
|
||||
INNER JOIN api_key_tag_access AS at ON at.tag_id = t.id
|
||||
WHERE at.api_key_id = ?
|
||||
ORDER BY t.name COLLATE NOCASE`,
|
||||
apiKeyID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query api key tag access: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tags := make([]Tag, 0)
|
||||
for rows.Next() {
|
||||
tag, err := scanTag(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan api key tag access: %w", err)
|
||||
}
|
||||
|
||||
tags = append(tags, *tag)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate api key tag access: %w", err)
|
||||
}
|
||||
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) ReplaceProjectAccess(ctx context.Context, apiKeyID int64, projectIDs []int64) error {
|
||||
if err := r.ClearProjectAccess(ctx, apiKeyID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, projectID := range normalizeIDList(projectIDs) {
|
||||
if _, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`INSERT OR IGNORE INTO api_key_project_access (api_key_id, project_id) VALUES (?, ?)`,
|
||||
apiKeyID,
|
||||
projectID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert api key project access: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) ReplaceTagAccess(ctx context.Context, apiKeyID int64, tagIDs []int64) error {
|
||||
if err := r.ClearTagAccess(ctx, apiKeyID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, tagID := range normalizeIDList(tagIDs) {
|
||||
if _, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`INSERT OR IGNORE INTO api_key_tag_access (api_key_id, tag_id) VALUES (?, ?)`,
|
||||
apiKeyID,
|
||||
tagID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert api key tag access: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) ClearProjectAccess(ctx context.Context, apiKeyID int64) error {
|
||||
if _, err := r.q.ExecContext(ctx, `DELETE FROM api_key_project_access WHERE api_key_id = ?`, apiKeyID); err != nil {
|
||||
return fmt.Errorf("delete api key project access: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) ClearTagAccess(ctx context.Context, apiKeyID int64) error {
|
||||
if _, err := r.q.ExecContext(ctx, `DELETE FROM api_key_tag_access WHERE api_key_id = ?`, apiKeyID); err != nil {
|
||||
return fmt.Errorf("delete api key tag access: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) ListAccessibleProjects(ctx context.Context, apiKeyID int64, scopeMode ScopeMode) ([]Project, error) {
|
||||
rows, err := r.q.QueryContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
p.id,
|
||||
p.name,
|
||||
p.slug,
|
||||
p.description,
|
||||
p.is_active,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM projects AS p
|
||||
WHERE p.is_active = 1 AND (
|
||||
? = 'all_projects'
|
||||
OR (
|
||||
? = 'project_allow_list'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM api_key_project_access AS ap
|
||||
WHERE ap.api_key_id = ?
|
||||
AND ap.project_id = p.id
|
||||
)
|
||||
)
|
||||
OR (
|
||||
? = 'project_deny_list'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM api_key_project_access AS ap
|
||||
WHERE ap.api_key_id = ?
|
||||
AND ap.project_id = p.id
|
||||
)
|
||||
)
|
||||
OR (
|
||||
? = 'tag_allow_list'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM project_tags AS pt
|
||||
INNER JOIN api_key_tag_access AS at ON at.tag_id = pt.tag_id
|
||||
WHERE at.api_key_id = ?
|
||||
AND pt.project_id = p.id
|
||||
)
|
||||
)
|
||||
OR (
|
||||
? = 'tag_deny_list'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM project_tags AS pt
|
||||
INNER JOIN api_key_tag_access AS at ON at.tag_id = pt.tag_id
|
||||
WHERE at.api_key_id = ?
|
||||
AND pt.project_id = p.id
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY p.name COLLATE NOCASE`,
|
||||
scopeMode,
|
||||
scopeMode, apiKeyID,
|
||||
scopeMode, apiKeyID,
|
||||
scopeMode, apiKeyID,
|
||||
scopeMode, apiKeyID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query accessible projects: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
projects := make([]Project, 0)
|
||||
for rows.Next() {
|
||||
project, err := scanProject(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan accessible project: %w", err)
|
||||
}
|
||||
|
||||
projects = append(projects, *project)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate accessible projects: %w", err)
|
||||
}
|
||||
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
func (r *APIKeyRepository) HasProjectAccess(ctx context.Context, apiKeyID int64, scopeMode ScopeMode, projectID int64) (bool, error) {
|
||||
var exists int
|
||||
if err := r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM projects AS p
|
||||
WHERE p.id = ?
|
||||
AND p.is_active = 1
|
||||
AND (
|
||||
? = 'all_projects'
|
||||
OR (
|
||||
? = 'project_allow_list'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM api_key_project_access AS ap
|
||||
WHERE ap.api_key_id = ?
|
||||
AND ap.project_id = p.id
|
||||
)
|
||||
)
|
||||
OR (
|
||||
? = 'project_deny_list'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM api_key_project_access AS ap
|
||||
WHERE ap.api_key_id = ?
|
||||
AND ap.project_id = p.id
|
||||
)
|
||||
)
|
||||
OR (
|
||||
? = 'tag_allow_list'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM project_tags AS pt
|
||||
INNER JOIN api_key_tag_access AS at ON at.tag_id = pt.tag_id
|
||||
WHERE at.api_key_id = ?
|
||||
AND pt.project_id = p.id
|
||||
)
|
||||
)
|
||||
OR (
|
||||
? = 'tag_deny_list'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM project_tags AS pt
|
||||
INNER JOIN api_key_tag_access AS at ON at.tag_id = pt.tag_id
|
||||
WHERE at.api_key_id = ?
|
||||
AND pt.project_id = p.id
|
||||
)
|
||||
)
|
||||
)
|
||||
)`,
|
||||
projectID,
|
||||
scopeMode,
|
||||
scopeMode, apiKeyID,
|
||||
scopeMode, apiKeyID,
|
||||
scopeMode, apiKeyID,
|
||||
scopeMode, apiKeyID,
|
||||
).Scan(&exists); err != nil {
|
||||
return false, fmt.Errorf("query api key project access: %w", err)
|
||||
}
|
||||
|
||||
return exists == 1, nil
|
||||
}
|
||||
|
||||
func scanAPIKey(scanner rowScanner) (*APIKey, error) {
|
||||
var (
|
||||
key APIKey
|
||||
scopeMode string
|
||||
canDownload int
|
||||
canUpload int
|
||||
canDelete int
|
||||
canManageProjects int
|
||||
isActive int
|
||||
expiresAtRaw sql.NullString
|
||||
createdAtRaw string
|
||||
updatedAtRaw string
|
||||
lastUsedAtRaw sql.NullString
|
||||
createdByUserIDRaw sql.NullInt64
|
||||
)
|
||||
|
||||
if err := scanner.Scan(
|
||||
&key.ID,
|
||||
&key.Name,
|
||||
&key.KeyPrefix,
|
||||
&key.KeyHash,
|
||||
&key.Description,
|
||||
&scopeMode,
|
||||
&canDownload,
|
||||
&canUpload,
|
||||
&canDelete,
|
||||
&canManageProjects,
|
||||
&isActive,
|
||||
&expiresAtRaw,
|
||||
&createdAtRaw,
|
||||
&updatedAtRaw,
|
||||
&lastUsedAtRaw,
|
||||
&createdByUserIDRaw,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdAt, err := parseTimestamp(createdAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse api key created_at: %w", err)
|
||||
}
|
||||
|
||||
updatedAt, err := parseTimestamp(updatedAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse api key updated_at: %w", err)
|
||||
}
|
||||
|
||||
expiresAt, err := parseNullableTimestamp(expiresAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse api key expires_at: %w", err)
|
||||
}
|
||||
|
||||
lastUsedAt, err := parseNullableTimestamp(lastUsedAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse api key last_used_at: %w", err)
|
||||
}
|
||||
|
||||
key.ScopeMode = ScopeMode(scopeMode)
|
||||
key.CanDownload = canDownload == 1
|
||||
key.CanUpload = canUpload == 1
|
||||
key.CanDelete = canDelete == 1
|
||||
key.CanManageProjects = canManageProjects == 1
|
||||
key.IsActive = isActive == 1
|
||||
key.ExpiresAt = expiresAt
|
||||
key.CreatedAt = createdAt
|
||||
key.UpdatedAt = updatedAt
|
||||
key.LastUsedAt = lastUsedAt
|
||||
if createdByUserIDRaw.Valid {
|
||||
key.CreatedByUserID = &createdByUserIDRaw.Int64
|
||||
}
|
||||
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
func scanAPIKeyListItem(scanner rowScanner) (*APIKeyListItem, error) {
|
||||
var (
|
||||
item APIKeyListItem
|
||||
scopeMode string
|
||||
canDownload int
|
||||
canUpload int
|
||||
canDelete int
|
||||
canManageProjects int
|
||||
isActive int
|
||||
expiresAtRaw sql.NullString
|
||||
createdAtRaw string
|
||||
updatedAtRaw string
|
||||
lastUsedAtRaw sql.NullString
|
||||
createdByUserIDRaw sql.NullInt64
|
||||
)
|
||||
|
||||
if err := scanner.Scan(
|
||||
&item.APIKey.ID,
|
||||
&item.APIKey.Name,
|
||||
&item.APIKey.KeyPrefix,
|
||||
&item.APIKey.KeyHash,
|
||||
&item.APIKey.Description,
|
||||
&scopeMode,
|
||||
&canDownload,
|
||||
&canUpload,
|
||||
&canDelete,
|
||||
&canManageProjects,
|
||||
&isActive,
|
||||
&expiresAtRaw,
|
||||
&createdAtRaw,
|
||||
&updatedAtRaw,
|
||||
&lastUsedAtRaw,
|
||||
&createdByUserIDRaw,
|
||||
&item.ProjectRuleCount,
|
||||
&item.TagRuleCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdAt, err := parseTimestamp(createdAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse api key list created_at: %w", err)
|
||||
}
|
||||
|
||||
updatedAt, err := parseTimestamp(updatedAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse api key list updated_at: %w", err)
|
||||
}
|
||||
|
||||
expiresAt, err := parseNullableTimestamp(expiresAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse api key list expires_at: %w", err)
|
||||
}
|
||||
|
||||
lastUsedAt, err := parseNullableTimestamp(lastUsedAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse api key list last_used_at: %w", err)
|
||||
}
|
||||
|
||||
item.APIKey.ScopeMode = ScopeMode(scopeMode)
|
||||
item.APIKey.CanDownload = canDownload == 1
|
||||
item.APIKey.CanUpload = canUpload == 1
|
||||
item.APIKey.CanDelete = canDelete == 1
|
||||
item.APIKey.CanManageProjects = canManageProjects == 1
|
||||
item.APIKey.IsActive = isActive == 1
|
||||
item.APIKey.ExpiresAt = expiresAt
|
||||
item.APIKey.CreatedAt = createdAt
|
||||
item.APIKey.UpdatedAt = updatedAt
|
||||
item.APIKey.LastUsedAt = lastUsedAt
|
||||
if createdByUserIDRaw.Valid {
|
||||
item.APIKey.CreatedByUserID = &createdByUserIDRaw.Int64
|
||||
}
|
||||
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func nullableTimestampValue(value *time.Time) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return formatTimestamp(value.UTC())
|
||||
}
|
||||
|
||||
func normalizeIDList(values []int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(values))
|
||||
normalized := make([]int64, 0, len(values))
|
||||
|
||||
for _, value := range values {
|
||||
if value <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[value] = struct{}{}
|
||||
normalized = append(normalized, value)
|
||||
}
|
||||
|
||||
sort.Slice(normalized, func(i, j int) bool {
|
||||
return normalized[i] < normalized[j]
|
||||
})
|
||||
|
||||
return normalized
|
||||
}
|
||||
19
internal/db/errors.go
Normal file
19
internal/db/errors.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("record not found")
|
||||
ErrConflict = errors.New("record conflict")
|
||||
)
|
||||
|
||||
func isUniqueConstraintError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.Contains(strings.ToLower(err.Error()), "unique constraint failed")
|
||||
}
|
||||
146
internal/db/migrate.go
Normal file
146
internal/db/migrate.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const migrationsTableDDL = `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
checksum_sha256 TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
`
|
||||
|
||||
func Migrate(ctx context.Context, database *sql.DB, migrationsDir string) error {
|
||||
if migrationsDir == "" {
|
||||
return fmt.Errorf("migrations dir is required")
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(ctx, migrationsTableDDL); err != nil {
|
||||
return fmt.Errorf("ensure schema_migrations table: %w", err)
|
||||
}
|
||||
|
||||
applied, err := appliedMigrations(ctx, database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
files, err := listMigrationFiles(migrationsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, filename := range files {
|
||||
fullPath := filepath.Join(migrationsDir, filename)
|
||||
|
||||
contents, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", filename, err)
|
||||
}
|
||||
|
||||
checksum := checksum(contents)
|
||||
if appliedChecksum, ok := applied[filename]; ok {
|
||||
if appliedChecksum != checksum {
|
||||
return fmt.Errorf("migration %s checksum mismatch: applied=%s current=%s", filename, appliedChecksum, checksum)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if err := applyMigration(ctx, database, filename, checksum, string(contents)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func appliedMigrations(ctx context.Context, database *sql.DB) (map[string]string, error) {
|
||||
rows, err := database.QueryContext(ctx, `SELECT name, checksum_sha256 FROM schema_migrations`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load applied migrations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
applied := make(map[string]string)
|
||||
for rows.Next() {
|
||||
var name string
|
||||
var checksum string
|
||||
|
||||
if err := rows.Scan(&name, &checksum); err != nil {
|
||||
return nil, fmt.Errorf("scan applied migration: %w", err)
|
||||
}
|
||||
|
||||
applied[name] = checksum
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate applied migrations: %w", err)
|
||||
}
|
||||
|
||||
return applied, nil
|
||||
}
|
||||
|
||||
func listMigrationFiles(migrationsDir string) ([]string, error) {
|
||||
entries, err := os.ReadDir(migrationsDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migrations dir: %w", err)
|
||||
}
|
||||
|
||||
files := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".sql" {
|
||||
continue
|
||||
}
|
||||
|
||||
files = append(files, entry.Name())
|
||||
}
|
||||
|
||||
slices.Sort(files)
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func applyMigration(ctx context.Context, database *sql.DB, filename, checksum, sqlText string) error {
|
||||
tx, err := database.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration %s: %w", filename, err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(sqlText) != "" {
|
||||
if _, err := tx.ExecContext(ctx, sqlText); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("execute migration %s: %w", filename, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO schema_migrations (name, checksum_sha256) VALUES (?, ?)`,
|
||||
filename,
|
||||
checksum,
|
||||
); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("record migration %s: %w", filename, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", filename, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func checksum(contents []byte) string {
|
||||
sum := sha256.Sum256(contents)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
294
internal/db/migrate_test.go
Normal file
294
internal/db/migrate_test.go
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
package db_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"update_server/internal/db"
|
||||
)
|
||||
|
||||
func TestMigrateAppliesCoreSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
sqlitePath := filepath.Join(t.TempDir(), "update-server.sqlite")
|
||||
|
||||
database, err := db.Open(ctx, sqlitePath)
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
migrationsDir := projectMigrationsDir(t)
|
||||
if err := db.Migrate(ctx, database, migrationsDir); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Migrate(ctx, database, migrationsDir); err != nil {
|
||||
t.Fatalf("reapply migrations: %v", err)
|
||||
}
|
||||
|
||||
expectedTables := []string{
|
||||
"schema_migrations",
|
||||
"users",
|
||||
"projects",
|
||||
"tags",
|
||||
"project_tags",
|
||||
"releases",
|
||||
"api_keys",
|
||||
"api_key_project_access",
|
||||
"api_key_tag_access",
|
||||
"sessions",
|
||||
"audit_logs",
|
||||
}
|
||||
|
||||
for _, tableName := range expectedTables {
|
||||
if !tableExists(t, database, tableName) {
|
||||
t.Fatalf("expected table %q to exist", tableName)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO users (email, password_hash, role) VALUES (?, ?, ?)`,
|
||||
"admin@example.com",
|
||||
"hashed-password",
|
||||
"admin",
|
||||
); err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO projects (name, slug, description) VALUES (?, ?, ?)`,
|
||||
"Desktop App",
|
||||
"desktop-app",
|
||||
"Primary desktop client",
|
||||
); err != nil {
|
||||
t.Fatalf("insert project: %v", err)
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO tags (name, slug, description) VALUES (?, ?, ?)`,
|
||||
"Windows",
|
||||
"windows",
|
||||
"Windows releases",
|
||||
); err != nil {
|
||||
t.Fatalf("insert tag: %v", err)
|
||||
}
|
||||
|
||||
var projectID int64
|
||||
if err := database.QueryRowContext(ctx, `SELECT id FROM projects WHERE slug = ?`, "desktop-app").Scan(&projectID); err != nil {
|
||||
t.Fatalf("load project id: %v", err)
|
||||
}
|
||||
|
||||
var tagID int64
|
||||
if err := database.QueryRowContext(ctx, `SELECT id FROM tags WHERE slug = ?`, "windows").Scan(&tagID); err != nil {
|
||||
t.Fatalf("load tag id: %v", err)
|
||||
}
|
||||
|
||||
var userID int64
|
||||
if err := database.QueryRowContext(ctx, `SELECT id FROM users WHERE email = ?`, "admin@example.com").Scan(&userID); err != nil {
|
||||
t.Fatalf("load user id: %v", err)
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO project_tags (project_id, tag_id) VALUES (?, ?)`,
|
||||
projectID,
|
||||
tagID,
|
||||
); err != nil {
|
||||
t.Fatalf("insert project tag: %v", err)
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO releases (project_id, version, build, filename, storage_path, checksum_sha256, size_bytes, content_type, release_notes, uploaded_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
projectID,
|
||||
"1.0.0",
|
||||
"",
|
||||
"desktop-app-1.0.0.zip",
|
||||
"artifacts/desktop-app/1.0.0/desktop-app-1.0.0.zip",
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
1024,
|
||||
"application/zip",
|
||||
"Initial release",
|
||||
userID,
|
||||
); err != nil {
|
||||
t.Fatalf("insert release: %v", err)
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO api_keys (name, key_prefix, key_hash, description, scope_mode, can_download, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
"Desktop Clients",
|
||||
"updsrv_project",
|
||||
"hash-project",
|
||||
"Project-scoped desktop client access",
|
||||
"project_allow_list",
|
||||
1,
|
||||
userID,
|
||||
); err != nil {
|
||||
t.Fatalf("insert project api key: %v", err)
|
||||
}
|
||||
|
||||
var projectAPIKeyID int64
|
||||
if err := database.QueryRowContext(ctx, `SELECT id FROM api_keys WHERE key_prefix = ?`, "updsrv_project").Scan(&projectAPIKeyID); err != nil {
|
||||
t.Fatalf("load project api key id: %v", err)
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO api_key_project_access (api_key_id, project_id) VALUES (?, ?)`,
|
||||
projectAPIKeyID,
|
||||
projectID,
|
||||
); err != nil {
|
||||
t.Fatalf("insert api key project access: %v", err)
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO api_keys (name, key_prefix, key_hash, description, scope_mode, can_download, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
"Tagged Clients",
|
||||
"updsrv_tag",
|
||||
"hash-tag",
|
||||
"Tag-scoped desktop client access",
|
||||
"tag_allow_list",
|
||||
1,
|
||||
userID,
|
||||
); err != nil {
|
||||
t.Fatalf("insert tag api key: %v", err)
|
||||
}
|
||||
|
||||
var tagAPIKeyID int64
|
||||
if err := database.QueryRowContext(ctx, `SELECT id FROM api_keys WHERE key_prefix = ?`, "updsrv_tag").Scan(&tagAPIKeyID); err != nil {
|
||||
t.Fatalf("load tag api key id: %v", err)
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO api_key_tag_access (api_key_id, tag_id) VALUES (?, ?)`,
|
||||
tagAPIKeyID,
|
||||
tagID,
|
||||
); err != nil {
|
||||
t.Fatalf("insert api key tag access: %v", err)
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO sessions (user_id, token_hash, expires_at, ip_address, user_agent) VALUES (?, ?, ?, ?, ?)`,
|
||||
userID,
|
||||
"session-hash",
|
||||
"2030-01-01T00:00:00Z",
|
||||
"127.0.0.1",
|
||||
"test-agent",
|
||||
); err != nil {
|
||||
t.Fatalf("insert session: %v", err)
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO audit_logs (actor_user_id, api_key_id, action, target_type, target_id, target_identifier, metadata_json, ip_address) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
userID,
|
||||
tagAPIKeyID,
|
||||
"api_key.created",
|
||||
"api_key",
|
||||
tagAPIKeyID,
|
||||
"updsrv_tag",
|
||||
`{"source":"test"}`,
|
||||
"127.0.0.1",
|
||||
); err != nil {
|
||||
t.Fatalf("insert audit log: %v", err)
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO api_key_project_access (api_key_id, project_id) VALUES (?, ?)`,
|
||||
tagAPIKeyID,
|
||||
projectID,
|
||||
); err == nil {
|
||||
t.Fatal("expected project access insert for tag-scoped key to fail")
|
||||
}
|
||||
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO api_keys (name, key_prefix, key_hash, scope_mode) VALUES (?, ?, ?, ?)`,
|
||||
"Broken Key",
|
||||
"updsrv_invalid",
|
||||
"hash-invalid",
|
||||
"invalid_scope",
|
||||
); err == nil {
|
||||
t.Fatal("expected invalid scope_mode insert to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func projectMigrationsDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("resolve caller path")
|
||||
}
|
||||
|
||||
return filepath.Join(filepath.Dir(filename), "..", "..", "migrations")
|
||||
}
|
||||
|
||||
func tableExists(t *testing.T, database *sql.DB, tableName string) bool {
|
||||
t.Helper()
|
||||
|
||||
var exists int
|
||||
query := `SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?)`
|
||||
if err := database.QueryRowContext(context.Background(), query, tableName).Scan(&exists); err != nil {
|
||||
t.Fatalf("check table %s: %v", tableName, err)
|
||||
}
|
||||
|
||||
return exists == 1
|
||||
}
|
||||
|
||||
func TestMigrateRejectsEditedAppliedMigrations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
tempDir := t.TempDir()
|
||||
sqlitePath := filepath.Join(tempDir, "update-server.sqlite")
|
||||
|
||||
database, err := db.Open(ctx, sqlitePath)
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
migrationsDir := filepath.Join(tempDir, "migrations")
|
||||
if err := os.MkdirAll(migrationsDir, 0o755); err != nil {
|
||||
t.Fatalf("create migrations dir: %v", err)
|
||||
}
|
||||
|
||||
firstMigrationPath := filepath.Join(migrationsDir, "0001_test.sql")
|
||||
if err := os.WriteFile(firstMigrationPath, []byte(`CREATE TABLE sample (id INTEGER PRIMARY KEY);`), 0o644); err != nil {
|
||||
t.Fatalf("write migration: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Migrate(ctx, database, migrationsDir); err != nil {
|
||||
t.Fatalf("apply migration: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(firstMigrationPath, []byte(`CREATE TABLE sample (id INTEGER PRIMARY KEY, name TEXT);`), 0o644); err != nil {
|
||||
t.Fatalf("rewrite migration: %v", err)
|
||||
}
|
||||
|
||||
err = db.Migrate(ctx, database, migrationsDir)
|
||||
if err == nil {
|
||||
t.Fatal("expected checksum mismatch error")
|
||||
}
|
||||
|
||||
expectedMessage := "checksum mismatch"
|
||||
if !strings.Contains(err.Error(), expectedMessage) {
|
||||
t.Fatalf("expected error containing %q, got %v", expectedMessage, err)
|
||||
}
|
||||
}
|
||||
177
internal/db/models.go
Normal file
177
internal/db/models.go
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
package db
|
||||
|
||||
import "time"
|
||||
|
||||
type ScopeMode string
|
||||
|
||||
const (
|
||||
ScopeModeAllProjects ScopeMode = "all_projects"
|
||||
ScopeModeProjectAllowList ScopeMode = "project_allow_list"
|
||||
ScopeModeProjectDenyList ScopeMode = "project_deny_list"
|
||||
ScopeModeTagAllowList ScopeMode = "tag_allow_list"
|
||||
ScopeModeTagDenyList ScopeMode = "tag_deny_list"
|
||||
)
|
||||
|
||||
func (m ScopeMode) Valid() bool {
|
||||
switch m {
|
||||
case ScopeModeAllProjects,
|
||||
ScopeModeProjectAllowList,
|
||||
ScopeModeProjectDenyList,
|
||||
ScopeModeTagAllowList,
|
||||
ScopeModeTagDenyList:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (m ScopeMode) UsesProjectRules() bool {
|
||||
return m == ScopeModeProjectAllowList || m == ScopeModeProjectDenyList
|
||||
}
|
||||
|
||||
func (m ScopeMode) UsesTagRules() bool {
|
||||
return m == ScopeModeTagAllowList || m == ScopeModeTagDenyList
|
||||
}
|
||||
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
UserRoleAdmin UserRole = "admin"
|
||||
UserRoleEditor UserRole = "editor"
|
||||
UserRoleViewer UserRole = "viewer"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int64
|
||||
Email string
|
||||
PasswordHash string
|
||||
Role UserRole
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
LastLoginAt *time.Time
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
ID int64
|
||||
Name string
|
||||
Slug string
|
||||
Description string
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type ProjectListItem struct {
|
||||
Project Project
|
||||
TagCount int
|
||||
ReleaseCount int
|
||||
}
|
||||
|
||||
type Tag struct {
|
||||
ID int64
|
||||
Name string
|
||||
Slug string
|
||||
Description string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type TagListItem struct {
|
||||
Tag Tag
|
||||
ProjectCount int
|
||||
}
|
||||
|
||||
type Release struct {
|
||||
ID int64
|
||||
ProjectID int64
|
||||
Version string
|
||||
Build string
|
||||
Filename string
|
||||
StoragePath string
|
||||
ChecksumSHA256 string
|
||||
SizeBytes int64
|
||||
ContentType string
|
||||
ReleaseNotes string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
UploadedByUserID *int64
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
type ReleaseListItem struct {
|
||||
Release Release
|
||||
UploadedByEmail string
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
ID int64
|
||||
Name string
|
||||
KeyPrefix string
|
||||
KeyHash string
|
||||
Description string
|
||||
ScopeMode ScopeMode
|
||||
CanDownload bool
|
||||
CanUpload bool
|
||||
CanDelete bool
|
||||
CanManageProjects bool
|
||||
IsActive bool
|
||||
ExpiresAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
LastUsedAt *time.Time
|
||||
CreatedByUserID *int64
|
||||
}
|
||||
|
||||
func (k APIKey) Expired(now time.Time) bool {
|
||||
return k.ExpiresAt != nil && !k.ExpiresAt.After(now.UTC())
|
||||
}
|
||||
|
||||
type APIKeyListItem struct {
|
||||
APIKey APIKey
|
||||
ProjectRuleCount int
|
||||
TagRuleCount int
|
||||
AccessiblePreview int
|
||||
}
|
||||
|
||||
type APIKeyProjectAccess struct {
|
||||
APIKeyID int64
|
||||
ProjectID int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type APIKeyTagAccess struct {
|
||||
APIKeyID int64
|
||||
TagID int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
TokenHash string
|
||||
ExpiresAt time.Time
|
||||
LastSeenAt *time.Time
|
||||
InvalidatedAt *time.Time
|
||||
IPAddress string
|
||||
UserAgent string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type SessionWithUser struct {
|
||||
Session Session
|
||||
User User
|
||||
}
|
||||
|
||||
type AuditLog struct {
|
||||
ID int64
|
||||
ActorUserID *int64
|
||||
APIKeyID *int64
|
||||
Action string
|
||||
TargetType string
|
||||
TargetID *int64
|
||||
TargetIdentifier string
|
||||
MetadataJSON string
|
||||
IPAddress string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
60
internal/db/open.go
Normal file
60
internal/db/open.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
const sqliteDriverName = "sqlite3"
|
||||
|
||||
func Open(ctx context.Context, sqlitePath string) (*sql.DB, error) {
|
||||
if sqlitePath == "" {
|
||||
return nil, fmt.Errorf("sqlite path is required")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(sqlitePath), 0o750); err != nil {
|
||||
return nil, fmt.Errorf("create sqlite dir: %w", err)
|
||||
}
|
||||
|
||||
database, err := sql.Open(sqliteDriverName, sqlitePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite database: %w", err)
|
||||
}
|
||||
|
||||
database.SetMaxOpenConns(1)
|
||||
database.SetMaxIdleConns(1)
|
||||
|
||||
if err := applyPragmas(ctx, database); err != nil {
|
||||
database.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := database.PingContext(ctx); err != nil {
|
||||
database.Close()
|
||||
return nil, fmt.Errorf("ping sqlite database: %w", err)
|
||||
}
|
||||
|
||||
return database, nil
|
||||
}
|
||||
|
||||
func applyPragmas(ctx context.Context, database *sql.DB) error {
|
||||
pragmas := []string{
|
||||
"PRAGMA foreign_keys = ON;",
|
||||
"PRAGMA journal_mode = WAL;",
|
||||
"PRAGMA busy_timeout = 5000;",
|
||||
"PRAGMA synchronous = NORMAL;",
|
||||
}
|
||||
|
||||
for _, pragma := range pragmas {
|
||||
if _, err := database.ExecContext(ctx, pragma); err != nil {
|
||||
return fmt.Errorf("apply sqlite pragma %q: %w", pragma, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
326
internal/db/projects.go
Normal file
326
internal/db/projects.go
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type CreateProjectParams struct {
|
||||
Name string
|
||||
Slug string
|
||||
Description string
|
||||
}
|
||||
|
||||
type UpdateProjectParams struct {
|
||||
Name string
|
||||
Slug string
|
||||
Description string
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) List(ctx context.Context) ([]ProjectListItem, error) {
|
||||
rows, err := r.q.QueryContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
p.id,
|
||||
p.name,
|
||||
p.slug,
|
||||
p.description,
|
||||
p.is_active,
|
||||
p.created_at,
|
||||
p.updated_at,
|
||||
COUNT(DISTINCT pt.tag_id) AS tag_count,
|
||||
COUNT(DISTINCT rel.id) AS release_count
|
||||
FROM projects AS p
|
||||
LEFT JOIN project_tags AS pt ON pt.project_id = p.id
|
||||
LEFT JOIN releases AS rel ON rel.project_id = p.id AND rel.is_active = 1
|
||||
GROUP BY p.id
|
||||
ORDER BY p.is_active DESC, p.updated_at DESC, p.created_at DESC, p.name COLLATE NOCASE`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query projects: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
projects := make([]ProjectListItem, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanProjectListItem(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan project list item: %w", err)
|
||||
}
|
||||
|
||||
projects = append(projects, *item)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate projects: %w", err)
|
||||
}
|
||||
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) GetByID(ctx context.Context, id int64) (*Project, error) {
|
||||
project, err := scanProject(r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT id, name, slug, description, is_active, created_at, updated_at
|
||||
FROM projects
|
||||
WHERE id = ?`,
|
||||
id,
|
||||
))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("scan project by id: %w", err)
|
||||
}
|
||||
|
||||
return project, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) GetBySlug(ctx context.Context, slug string) (*Project, error) {
|
||||
project, err := scanProject(r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT id, name, slug, description, is_active, created_at, updated_at
|
||||
FROM projects
|
||||
WHERE slug = ?
|
||||
LIMIT 1`,
|
||||
strings.TrimSpace(slug),
|
||||
))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("scan project by slug: %w", err)
|
||||
}
|
||||
|
||||
return project, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) Create(ctx context.Context, params CreateProjectParams) (*Project, error) {
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO projects (name, slug, description) VALUES (?, ?, ?)`,
|
||||
strings.TrimSpace(params.Name),
|
||||
strings.TrimSpace(params.Slug),
|
||||
strings.TrimSpace(params.Description),
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return nil, errors.Join(ErrConflict, fmt.Errorf("insert project: %w", err))
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("insert project: %w", err)
|
||||
}
|
||||
|
||||
projectID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load inserted project id: %w", err)
|
||||
}
|
||||
|
||||
return r.GetByID(ctx, projectID)
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) Update(ctx context.Context, projectID int64, params UpdateProjectParams) (*Project, error) {
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`UPDATE projects
|
||||
SET name = ?, slug = ?, description = ?
|
||||
WHERE id = ?`,
|
||||
strings.TrimSpace(params.Name),
|
||||
strings.TrimSpace(params.Slug),
|
||||
strings.TrimSpace(params.Description),
|
||||
projectID,
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return nil, errors.Join(ErrConflict, fmt.Errorf("update project: %w", err))
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("update project: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read updated project rows: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return r.GetByID(ctx, projectID)
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) SetActive(ctx context.Context, projectID int64, isActive bool) error {
|
||||
activeValue := 0
|
||||
if isActive {
|
||||
activeValue = 1
|
||||
}
|
||||
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`UPDATE projects
|
||||
SET is_active = ?
|
||||
WHERE id = ?`,
|
||||
activeValue,
|
||||
projectID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update project active state: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read affected project rows: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) ListTags(ctx context.Context, projectID int64) ([]Tag, error) {
|
||||
rows, err := r.q.QueryContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.slug,
|
||||
t.description,
|
||||
t.created_at,
|
||||
t.updated_at
|
||||
FROM tags AS t
|
||||
INNER JOIN project_tags AS pt ON pt.tag_id = t.id
|
||||
WHERE pt.project_id = ?
|
||||
ORDER BY t.name COLLATE NOCASE`,
|
||||
projectID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query project tags: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tags := make([]Tag, 0)
|
||||
for rows.Next() {
|
||||
tag, err := scanTag(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan project tag: %w", err)
|
||||
}
|
||||
|
||||
tags = append(tags, *tag)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate project tags: %w", err)
|
||||
}
|
||||
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) AttachTag(ctx context.Context, projectID, tagID int64) error {
|
||||
if _, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`INSERT OR IGNORE INTO project_tags (project_id, tag_id) VALUES (?, ?)`,
|
||||
projectID,
|
||||
tagID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert project tag link: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) DetachTag(ctx context.Context, projectID, tagID int64) error {
|
||||
if _, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`DELETE FROM project_tags WHERE project_id = ? AND tag_id = ?`,
|
||||
projectID,
|
||||
tagID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("delete project tag link: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanProject(scanner rowScanner) (*Project, error) {
|
||||
var (
|
||||
project Project
|
||||
isActive int
|
||||
createdAtRaw string
|
||||
updatedAtRaw string
|
||||
)
|
||||
|
||||
if err := scanner.Scan(
|
||||
&project.ID,
|
||||
&project.Name,
|
||||
&project.Slug,
|
||||
&project.Description,
|
||||
&isActive,
|
||||
&createdAtRaw,
|
||||
&updatedAtRaw,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdAt, err := parseTimestamp(createdAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse project created_at: %w", err)
|
||||
}
|
||||
|
||||
updatedAt, err := parseTimestamp(updatedAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse project updated_at: %w", err)
|
||||
}
|
||||
|
||||
project.IsActive = isActive == 1
|
||||
project.CreatedAt = createdAt
|
||||
project.UpdatedAt = updatedAt
|
||||
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
func scanProjectListItem(scanner rowScanner) (*ProjectListItem, error) {
|
||||
var (
|
||||
item ProjectListItem
|
||||
isActive int
|
||||
createdAtRaw string
|
||||
updatedAtRaw string
|
||||
)
|
||||
|
||||
if err := scanner.Scan(
|
||||
&item.Project.ID,
|
||||
&item.Project.Name,
|
||||
&item.Project.Slug,
|
||||
&item.Project.Description,
|
||||
&isActive,
|
||||
&createdAtRaw,
|
||||
&updatedAtRaw,
|
||||
&item.TagCount,
|
||||
&item.ReleaseCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdAt, err := parseTimestamp(createdAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse project list created_at: %w", err)
|
||||
}
|
||||
|
||||
updatedAt, err := parseTimestamp(updatedAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse project list updated_at: %w", err)
|
||||
}
|
||||
|
||||
item.Project.IsActive = isActive == 1
|
||||
item.Project.CreatedAt = createdAt
|
||||
item.Project.UpdatedAt = updatedAt
|
||||
|
||||
return &item, nil
|
||||
}
|
||||
285
internal/db/releases.go
Normal file
285
internal/db/releases.go
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type CreateReleaseParams struct {
|
||||
ProjectID int64
|
||||
Version string
|
||||
Build string
|
||||
Filename string
|
||||
StoragePath string
|
||||
ChecksumSHA256 string
|
||||
SizeBytes int64
|
||||
ContentType string
|
||||
ReleaseNotes string
|
||||
UploadedByUserID *int64
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
func (r *ReleaseRepository) Create(ctx context.Context, params CreateReleaseParams) (*Release, error) {
|
||||
isActive := 0
|
||||
if params.IsActive {
|
||||
isActive = 1
|
||||
}
|
||||
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO releases (
|
||||
project_id,
|
||||
version,
|
||||
build,
|
||||
filename,
|
||||
storage_path,
|
||||
checksum_sha256,
|
||||
size_bytes,
|
||||
content_type,
|
||||
release_notes,
|
||||
uploaded_by_user_id,
|
||||
is_active
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
params.ProjectID,
|
||||
strings.TrimSpace(params.Version),
|
||||
strings.TrimSpace(params.Build),
|
||||
strings.TrimSpace(params.Filename),
|
||||
strings.TrimSpace(params.StoragePath),
|
||||
strings.TrimSpace(params.ChecksumSHA256),
|
||||
params.SizeBytes,
|
||||
strings.TrimSpace(params.ContentType),
|
||||
strings.TrimSpace(params.ReleaseNotes),
|
||||
params.UploadedByUserID,
|
||||
isActive,
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return nil, errors.Join(ErrConflict, fmt.Errorf("insert release: %w", err))
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("insert release: %w", err)
|
||||
}
|
||||
|
||||
releaseID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load inserted release id: %w", err)
|
||||
}
|
||||
|
||||
return r.GetByID(ctx, releaseID)
|
||||
}
|
||||
|
||||
func (r *ReleaseRepository) GetByID(ctx context.Context, id int64) (*Release, error) {
|
||||
release, err := scanRelease(r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
id,
|
||||
project_id,
|
||||
version,
|
||||
build,
|
||||
filename,
|
||||
storage_path,
|
||||
checksum_sha256,
|
||||
size_bytes,
|
||||
content_type,
|
||||
release_notes,
|
||||
created_at,
|
||||
updated_at,
|
||||
uploaded_by_user_id,
|
||||
is_active
|
||||
FROM releases
|
||||
WHERE id = ?`,
|
||||
id,
|
||||
))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("scan release by id: %w", err)
|
||||
}
|
||||
|
||||
return release, nil
|
||||
}
|
||||
|
||||
func (r *ReleaseRepository) GetLatestByProjectID(ctx context.Context, projectID int64) (*Release, error) {
|
||||
release, err := scanRelease(r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
id,
|
||||
project_id,
|
||||
version,
|
||||
build,
|
||||
filename,
|
||||
storage_path,
|
||||
checksum_sha256,
|
||||
size_bytes,
|
||||
content_type,
|
||||
release_notes,
|
||||
created_at,
|
||||
updated_at,
|
||||
uploaded_by_user_id,
|
||||
is_active
|
||||
FROM releases
|
||||
WHERE project_id = ?
|
||||
AND is_active = 1
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 1`,
|
||||
projectID,
|
||||
))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("scan latest release by project id: %w", err)
|
||||
}
|
||||
|
||||
return release, nil
|
||||
}
|
||||
|
||||
func (r *ReleaseRepository) ListByProjectID(ctx context.Context, projectID int64) ([]ReleaseListItem, error) {
|
||||
rows, err := r.q.QueryContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
r.id,
|
||||
r.project_id,
|
||||
r.version,
|
||||
r.build,
|
||||
r.filename,
|
||||
r.storage_path,
|
||||
r.checksum_sha256,
|
||||
r.size_bytes,
|
||||
r.content_type,
|
||||
r.release_notes,
|
||||
r.created_at,
|
||||
r.updated_at,
|
||||
r.uploaded_by_user_id,
|
||||
r.is_active,
|
||||
COALESCE(u.email, '')
|
||||
FROM releases AS r
|
||||
LEFT JOIN users AS u ON u.id = r.uploaded_by_user_id
|
||||
WHERE r.project_id = ?
|
||||
ORDER BY r.created_at DESC, r.id DESC`,
|
||||
projectID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query project releases: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
releases := make([]ReleaseListItem, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanReleaseListItem(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan project release: %w", err)
|
||||
}
|
||||
|
||||
releases = append(releases, *item)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate project releases: %w", err)
|
||||
}
|
||||
|
||||
return releases, nil
|
||||
}
|
||||
|
||||
func scanRelease(scanner rowScanner) (*Release, error) {
|
||||
var (
|
||||
release Release
|
||||
createdAtRaw string
|
||||
updatedAtRaw string
|
||||
uploadedByUserID sql.NullInt64
|
||||
isActive int
|
||||
)
|
||||
|
||||
if err := scanner.Scan(
|
||||
&release.ID,
|
||||
&release.ProjectID,
|
||||
&release.Version,
|
||||
&release.Build,
|
||||
&release.Filename,
|
||||
&release.StoragePath,
|
||||
&release.ChecksumSHA256,
|
||||
&release.SizeBytes,
|
||||
&release.ContentType,
|
||||
&release.ReleaseNotes,
|
||||
&createdAtRaw,
|
||||
&updatedAtRaw,
|
||||
&uploadedByUserID,
|
||||
&isActive,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdAt, err := parseTimestamp(createdAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse release created_at: %w", err)
|
||||
}
|
||||
|
||||
updatedAt, err := parseTimestamp(updatedAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse release updated_at: %w", err)
|
||||
}
|
||||
|
||||
release.CreatedAt = createdAt
|
||||
release.UpdatedAt = updatedAt
|
||||
release.IsActive = isActive == 1
|
||||
if uploadedByUserID.Valid {
|
||||
release.UploadedByUserID = &uploadedByUserID.Int64
|
||||
}
|
||||
|
||||
return &release, nil
|
||||
}
|
||||
|
||||
func scanReleaseListItem(scanner rowScanner) (*ReleaseListItem, error) {
|
||||
var (
|
||||
item ReleaseListItem
|
||||
createdAtRaw string
|
||||
updatedAtRaw string
|
||||
uploadedByUserID sql.NullInt64
|
||||
isActive int
|
||||
)
|
||||
|
||||
if err := scanner.Scan(
|
||||
&item.Release.ID,
|
||||
&item.Release.ProjectID,
|
||||
&item.Release.Version,
|
||||
&item.Release.Build,
|
||||
&item.Release.Filename,
|
||||
&item.Release.StoragePath,
|
||||
&item.Release.ChecksumSHA256,
|
||||
&item.Release.SizeBytes,
|
||||
&item.Release.ContentType,
|
||||
&item.Release.ReleaseNotes,
|
||||
&createdAtRaw,
|
||||
&updatedAtRaw,
|
||||
&uploadedByUserID,
|
||||
&isActive,
|
||||
&item.UploadedByEmail,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdAt, err := parseTimestamp(createdAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse release list created_at: %w", err)
|
||||
}
|
||||
|
||||
updatedAt, err := parseTimestamp(updatedAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse release list updated_at: %w", err)
|
||||
}
|
||||
|
||||
item.Release.CreatedAt = createdAt
|
||||
item.Release.UpdatedAt = updatedAt
|
||||
item.Release.IsActive = isActive == 1
|
||||
if uploadedByUserID.Valid {
|
||||
item.Release.UploadedByUserID = &uploadedByUserID.Int64
|
||||
}
|
||||
|
||||
return &item, nil
|
||||
}
|
||||
284
internal/db/sessions.go
Normal file
284
internal/db/sessions.go
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CreateSessionParams struct {
|
||||
UserID int64
|
||||
TokenHash string
|
||||
ExpiresAt time.Time
|
||||
IPAddress string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
func (r *SessionRepository) Create(ctx context.Context, params CreateSessionParams) (*Session, error) {
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO sessions (user_id, token_hash, expires_at, ip_address, user_agent) VALUES (?, ?, ?, ?, ?)`,
|
||||
params.UserID,
|
||||
params.TokenHash,
|
||||
formatTimestamp(params.ExpiresAt),
|
||||
params.IPAddress,
|
||||
params.UserAgent,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert session: %w", err)
|
||||
}
|
||||
|
||||
sessionID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load inserted session id: %w", err)
|
||||
}
|
||||
|
||||
return r.GetByID(ctx, sessionID)
|
||||
}
|
||||
|
||||
func (r *SessionRepository) GetByID(ctx context.Context, id int64) (*Session, error) {
|
||||
session, err := scanSession(r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT id, user_id, token_hash, expires_at, last_seen_at, invalidated_at, ip_address, user_agent, created_at
|
||||
FROM sessions
|
||||
WHERE id = ?`,
|
||||
id,
|
||||
))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("scan session by id: %w", err)
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (r *SessionRepository) GetActiveWithUserByTokenHash(ctx context.Context, tokenHash string, now time.Time) (*SessionWithUser, error) {
|
||||
record, err := scanSessionWithUser(r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
s.id,
|
||||
s.user_id,
|
||||
s.token_hash,
|
||||
s.expires_at,
|
||||
s.last_seen_at,
|
||||
s.invalidated_at,
|
||||
s.ip_address,
|
||||
s.user_agent,
|
||||
s.created_at,
|
||||
u.id,
|
||||
u.email,
|
||||
u.password_hash,
|
||||
u.role,
|
||||
u.is_active,
|
||||
u.created_at,
|
||||
u.updated_at,
|
||||
u.last_login_at
|
||||
FROM sessions AS s
|
||||
INNER JOIN users AS u ON u.id = s.user_id
|
||||
WHERE s.token_hash = ?
|
||||
AND s.invalidated_at IS NULL
|
||||
AND s.expires_at > ?
|
||||
AND u.is_active = 1
|
||||
LIMIT 1`,
|
||||
tokenHash,
|
||||
formatTimestamp(now),
|
||||
))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("scan active session by token hash: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (r *SessionRepository) Touch(ctx context.Context, sessionID int64, seenAt time.Time) error {
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`UPDATE sessions SET last_seen_at = ? WHERE id = ?`,
|
||||
formatTimestamp(seenAt),
|
||||
sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update session last_seen_at: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read affected session rows: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SessionRepository) InvalidateByTokenHash(ctx context.Context, tokenHash string, invalidatedAt time.Time) error {
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`UPDATE sessions
|
||||
SET invalidated_at = ?
|
||||
WHERE token_hash = ?
|
||||
AND invalidated_at IS NULL`,
|
||||
formatTimestamp(invalidatedAt),
|
||||
tokenHash,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalidate session by token hash: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read invalidated session rows: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanSession(scanner rowScanner) (*Session, error) {
|
||||
var (
|
||||
session Session
|
||||
expiresAtRaw string
|
||||
lastSeenAtRaw sql.NullString
|
||||
invalidatedAtRaw sql.NullString
|
||||
createdAtRaw string
|
||||
)
|
||||
|
||||
if err := scanner.Scan(
|
||||
&session.ID,
|
||||
&session.UserID,
|
||||
&session.TokenHash,
|
||||
&expiresAtRaw,
|
||||
&lastSeenAtRaw,
|
||||
&invalidatedAtRaw,
|
||||
&session.IPAddress,
|
||||
&session.UserAgent,
|
||||
&createdAtRaw,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
expiresAt, err := parseTimestamp(expiresAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse session expires_at: %w", err)
|
||||
}
|
||||
|
||||
lastSeenAt, err := parseNullableTimestamp(lastSeenAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse session last_seen_at: %w", err)
|
||||
}
|
||||
|
||||
invalidatedAt, err := parseNullableTimestamp(invalidatedAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse session invalidated_at: %w", err)
|
||||
}
|
||||
|
||||
createdAt, err := parseTimestamp(createdAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse session created_at: %w", err)
|
||||
}
|
||||
|
||||
session.ExpiresAt = expiresAt
|
||||
session.LastSeenAt = lastSeenAt
|
||||
session.InvalidatedAt = invalidatedAt
|
||||
session.CreatedAt = createdAt
|
||||
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func scanSessionWithUser(scanner rowScanner) (*SessionWithUser, error) {
|
||||
var (
|
||||
record SessionWithUser
|
||||
sessionExpiresRaw string
|
||||
sessionLastSeenRaw sql.NullString
|
||||
sessionInvalidRaw sql.NullString
|
||||
sessionCreatedRaw string
|
||||
userRole string
|
||||
userIsActive int
|
||||
userCreatedRaw string
|
||||
userUpdatedRaw string
|
||||
userLastLoginRaw sql.NullString
|
||||
)
|
||||
|
||||
if err := scanner.Scan(
|
||||
&record.Session.ID,
|
||||
&record.Session.UserID,
|
||||
&record.Session.TokenHash,
|
||||
&sessionExpiresRaw,
|
||||
&sessionLastSeenRaw,
|
||||
&sessionInvalidRaw,
|
||||
&record.Session.IPAddress,
|
||||
&record.Session.UserAgent,
|
||||
&sessionCreatedRaw,
|
||||
&record.User.ID,
|
||||
&record.User.Email,
|
||||
&record.User.PasswordHash,
|
||||
&userRole,
|
||||
&userIsActive,
|
||||
&userCreatedRaw,
|
||||
&userUpdatedRaw,
|
||||
&userLastLoginRaw,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sessionExpiresAt, err := parseTimestamp(sessionExpiresRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse joined session expires_at: %w", err)
|
||||
}
|
||||
|
||||
sessionLastSeenAt, err := parseNullableTimestamp(sessionLastSeenRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse joined session last_seen_at: %w", err)
|
||||
}
|
||||
|
||||
sessionInvalidatedAt, err := parseNullableTimestamp(sessionInvalidRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse joined session invalidated_at: %w", err)
|
||||
}
|
||||
|
||||
sessionCreatedAt, err := parseTimestamp(sessionCreatedRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse joined session created_at: %w", err)
|
||||
}
|
||||
|
||||
userCreatedAt, err := parseTimestamp(userCreatedRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse joined user created_at: %w", err)
|
||||
}
|
||||
|
||||
userUpdatedAt, err := parseTimestamp(userUpdatedRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse joined user updated_at: %w", err)
|
||||
}
|
||||
|
||||
userLastLoginAt, err := parseNullableTimestamp(userLastLoginRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse joined user last_login_at: %w", err)
|
||||
}
|
||||
|
||||
record.Session.ExpiresAt = sessionExpiresAt
|
||||
record.Session.LastSeenAt = sessionLastSeenAt
|
||||
record.Session.InvalidatedAt = sessionInvalidatedAt
|
||||
record.Session.CreatedAt = sessionCreatedAt
|
||||
record.User.Role = UserRole(userRole)
|
||||
record.User.IsActive = userIsActive == 1
|
||||
record.User.CreatedAt = userCreatedAt
|
||||
record.User.UpdatedAt = userUpdatedAt
|
||||
record.User.LastLoginAt = userLastLoginAt
|
||||
|
||||
return &record, nil
|
||||
}
|
||||
131
internal/db/store.go
Normal file
131
internal/db/store.go
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type querier interface {
|
||||
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
||||
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
DB *sql.DB
|
||||
Users *UserRepository
|
||||
Projects *ProjectRepository
|
||||
Tags *TagRepository
|
||||
Releases *ReleaseRepository
|
||||
APIKeys *APIKeyRepository
|
||||
Sessions *SessionRepository
|
||||
AuditLogs *AuditLogRepository
|
||||
}
|
||||
|
||||
type TxStore struct {
|
||||
Tx *sql.Tx
|
||||
Users *UserRepository
|
||||
Projects *ProjectRepository
|
||||
Tags *TagRepository
|
||||
Releases *ReleaseRepository
|
||||
APIKeys *APIKeyRepository
|
||||
Sessions *SessionRepository
|
||||
AuditLogs *AuditLogRepository
|
||||
}
|
||||
|
||||
type UserRepository struct {
|
||||
q querier
|
||||
}
|
||||
|
||||
type ProjectRepository struct {
|
||||
q querier
|
||||
}
|
||||
|
||||
type TagRepository struct {
|
||||
q querier
|
||||
}
|
||||
|
||||
type ReleaseRepository struct {
|
||||
q querier
|
||||
}
|
||||
|
||||
type APIKeyRepository struct {
|
||||
q querier
|
||||
}
|
||||
|
||||
type SessionRepository struct {
|
||||
q querier
|
||||
}
|
||||
|
||||
type AuditLogRepository struct {
|
||||
q querier
|
||||
}
|
||||
|
||||
func NewStore(database *sql.DB) *Store {
|
||||
return &Store{
|
||||
DB: database,
|
||||
Users: &UserRepository{q: database},
|
||||
Projects: &ProjectRepository{q: database},
|
||||
Tags: &TagRepository{q: database},
|
||||
Releases: &ReleaseRepository{q: database},
|
||||
APIKeys: &APIKeyRepository{q: database},
|
||||
Sessions: &SessionRepository{q: database},
|
||||
AuditLogs: &AuditLogRepository{q: database},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
if s == nil || s.DB == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.DB.Close()
|
||||
}
|
||||
|
||||
func (s *Store) HealthCheck(ctx context.Context) error {
|
||||
if s == nil || s.DB == nil {
|
||||
return fmt.Errorf("database store is not initialized")
|
||||
}
|
||||
|
||||
return s.DB.PingContext(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) WithTx(ctx context.Context, fn func(*TxStore) error) error {
|
||||
if s == nil || s.DB == nil {
|
||||
return fmt.Errorf("database store is not initialized")
|
||||
}
|
||||
|
||||
tx, err := s.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
|
||||
if err := fn(newTxStore(tx)); err != nil {
|
||||
if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
|
||||
return errors.Join(err, fmt.Errorf("rollback transaction: %w", rollbackErr))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTxStore(tx *sql.Tx) *TxStore {
|
||||
return &TxStore{
|
||||
Tx: tx,
|
||||
Users: &UserRepository{q: tx},
|
||||
Projects: &ProjectRepository{q: tx},
|
||||
Tags: &TagRepository{q: tx},
|
||||
Releases: &ReleaseRepository{q: tx},
|
||||
APIKeys: &APIKeyRepository{q: tx},
|
||||
Sessions: &SessionRepository{q: tx},
|
||||
AuditLogs: &AuditLogRepository{q: tx},
|
||||
}
|
||||
}
|
||||
314
internal/db/tags.go
Normal file
314
internal/db/tags.go
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type CreateTagParams struct {
|
||||
Name string
|
||||
Slug string
|
||||
Description string
|
||||
}
|
||||
|
||||
type UpdateTagParams struct {
|
||||
Name string
|
||||
Slug string
|
||||
Description string
|
||||
}
|
||||
|
||||
func (r *TagRepository) List(ctx context.Context) ([]TagListItem, error) {
|
||||
rows, err := r.q.QueryContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.slug,
|
||||
t.description,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
COUNT(DISTINCT pt.project_id) AS project_count
|
||||
FROM tags AS t
|
||||
LEFT JOIN project_tags AS pt ON pt.tag_id = t.id
|
||||
GROUP BY t.id
|
||||
ORDER BY t.name COLLATE NOCASE`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query tags: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tags := make([]TagListItem, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanTagListItem(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan tag list item: %w", err)
|
||||
}
|
||||
|
||||
tags = append(tags, *item)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate tags: %w", err)
|
||||
}
|
||||
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (r *TagRepository) ListAvailableForProject(ctx context.Context, projectID int64) ([]Tag, error) {
|
||||
rows, err := r.q.QueryContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.slug,
|
||||
t.description,
|
||||
t.created_at,
|
||||
t.updated_at
|
||||
FROM tags AS t
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM project_tags AS pt
|
||||
WHERE pt.project_id = ?
|
||||
AND pt.tag_id = t.id
|
||||
)
|
||||
ORDER BY t.name COLLATE NOCASE`,
|
||||
projectID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query available project tags: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tags := make([]Tag, 0)
|
||||
for rows.Next() {
|
||||
tag, err := scanTag(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan available project tag: %w", err)
|
||||
}
|
||||
|
||||
tags = append(tags, *tag)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate available project tags: %w", err)
|
||||
}
|
||||
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (r *TagRepository) GetByID(ctx context.Context, id int64) (*Tag, error) {
|
||||
tag, err := scanTag(r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT id, name, slug, description, created_at, updated_at
|
||||
FROM tags
|
||||
WHERE id = ?`,
|
||||
id,
|
||||
))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("scan tag by id: %w", err)
|
||||
}
|
||||
|
||||
return tag, nil
|
||||
}
|
||||
|
||||
func (r *TagRepository) Create(ctx context.Context, params CreateTagParams) (*Tag, error) {
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO tags (name, slug, description) VALUES (?, ?, ?)`,
|
||||
strings.TrimSpace(params.Name),
|
||||
strings.TrimSpace(params.Slug),
|
||||
strings.TrimSpace(params.Description),
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return nil, errors.Join(ErrConflict, fmt.Errorf("insert tag: %w", err))
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("insert tag: %w", err)
|
||||
}
|
||||
|
||||
tagID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load inserted tag id: %w", err)
|
||||
}
|
||||
|
||||
return r.GetByID(ctx, tagID)
|
||||
}
|
||||
|
||||
func (r *TagRepository) Update(ctx context.Context, tagID int64, params UpdateTagParams) (*Tag, error) {
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`UPDATE tags
|
||||
SET name = ?, slug = ?, description = ?
|
||||
WHERE id = ?`,
|
||||
strings.TrimSpace(params.Name),
|
||||
strings.TrimSpace(params.Slug),
|
||||
strings.TrimSpace(params.Description),
|
||||
tagID,
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return nil, errors.Join(ErrConflict, fmt.Errorf("update tag: %w", err))
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("update tag: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read updated tag rows: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return r.GetByID(ctx, tagID)
|
||||
}
|
||||
|
||||
func (r *TagRepository) Delete(ctx context.Context, tagID int64) error {
|
||||
var projectCount int
|
||||
if err := r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT COUNT(1) FROM project_tags WHERE tag_id = ?`,
|
||||
tagID,
|
||||
).Scan(&projectCount); err != nil {
|
||||
return fmt.Errorf("count tag assignments: %w", err)
|
||||
}
|
||||
|
||||
if projectCount > 0 {
|
||||
return errors.Join(ErrConflict, fmt.Errorf("tag is assigned to %d project(s)", projectCount))
|
||||
}
|
||||
|
||||
result, err := r.q.ExecContext(ctx, `DELETE FROM tags WHERE id = ?`, tagID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete tag: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read deleted tag rows: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *TagRepository) ListProjects(ctx context.Context, tagID int64) ([]Project, error) {
|
||||
rows, err := r.q.QueryContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
p.id,
|
||||
p.name,
|
||||
p.slug,
|
||||
p.description,
|
||||
p.is_active,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM projects AS p
|
||||
INNER JOIN project_tags AS pt ON pt.project_id = p.id
|
||||
WHERE pt.tag_id = ?
|
||||
ORDER BY p.name COLLATE NOCASE`,
|
||||
tagID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query tag projects: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
projects := make([]Project, 0)
|
||||
for rows.Next() {
|
||||
project, err := scanProject(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan tag project: %w", err)
|
||||
}
|
||||
|
||||
projects = append(projects, *project)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate tag projects: %w", err)
|
||||
}
|
||||
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
func scanTag(scanner rowScanner) (*Tag, error) {
|
||||
var (
|
||||
tag Tag
|
||||
createdAtRaw string
|
||||
updatedAtRaw string
|
||||
)
|
||||
|
||||
if err := scanner.Scan(
|
||||
&tag.ID,
|
||||
&tag.Name,
|
||||
&tag.Slug,
|
||||
&tag.Description,
|
||||
&createdAtRaw,
|
||||
&updatedAtRaw,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdAt, err := parseTimestamp(createdAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse tag created_at: %w", err)
|
||||
}
|
||||
|
||||
updatedAt, err := parseTimestamp(updatedAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse tag updated_at: %w", err)
|
||||
}
|
||||
|
||||
tag.CreatedAt = createdAt
|
||||
tag.UpdatedAt = updatedAt
|
||||
|
||||
return &tag, nil
|
||||
}
|
||||
|
||||
func scanTagListItem(scanner rowScanner) (*TagListItem, error) {
|
||||
var (
|
||||
item TagListItem
|
||||
createdAtRaw string
|
||||
updatedAtRaw string
|
||||
)
|
||||
|
||||
if err := scanner.Scan(
|
||||
&item.Tag.ID,
|
||||
&item.Tag.Name,
|
||||
&item.Tag.Slug,
|
||||
&item.Tag.Description,
|
||||
&createdAtRaw,
|
||||
&updatedAtRaw,
|
||||
&item.ProjectCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdAt, err := parseTimestamp(createdAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse tag list created_at: %w", err)
|
||||
}
|
||||
|
||||
updatedAt, err := parseTimestamp(updatedAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse tag list updated_at: %w", err)
|
||||
}
|
||||
|
||||
item.Tag.CreatedAt = createdAt
|
||||
item.Tag.UpdatedAt = updatedAt
|
||||
|
||||
return &item, nil
|
||||
}
|
||||
38
internal/db/time.go
Normal file
38
internal/db/time.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func formatTimestamp(value time.Time) string {
|
||||
return value.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func parseTimestamp(raw string) (time.Time, error) {
|
||||
parsed, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("parse timestamp %q: %w", raw, err)
|
||||
}
|
||||
|
||||
return parsed.UTC(), nil
|
||||
}
|
||||
|
||||
func parseNullableTimestamp(raw sql.NullString) (*time.Time, error) {
|
||||
if !raw.Valid || strings.TrimSpace(raw.String) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
parsed, err := parseTimestamp(raw.String)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &parsed, nil
|
||||
}
|
||||
165
internal/db/users.go
Normal file
165
internal/db/users.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CreateUserParams struct {
|
||||
Email string
|
||||
PasswordHash string
|
||||
Role UserRole
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
func (r *UserRepository) HasActiveAdmin(ctx context.Context) (bool, error) {
|
||||
var exists int
|
||||
if err := r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM users WHERE role = ? AND is_active = 1 LIMIT 1)`,
|
||||
UserRoleAdmin,
|
||||
).Scan(&exists); err != nil {
|
||||
return false, fmt.Errorf("query active admin existence: %w", err)
|
||||
}
|
||||
|
||||
return exists == 1, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByID(ctx context.Context, id int64) (*User, error) {
|
||||
user, err := scanUser(r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT id, email, password_hash, role, is_active, created_at, updated_at, last_login_at
|
||||
FROM users
|
||||
WHERE id = ?`,
|
||||
id,
|
||||
))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("scan user by id: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*User, error) {
|
||||
user, err := scanUser(r.q.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT id, email, password_hash, role, is_active, created_at, updated_at, last_login_at
|
||||
FROM users
|
||||
WHERE email = ? COLLATE NOCASE
|
||||
LIMIT 1`,
|
||||
strings.TrimSpace(email),
|
||||
))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("scan user by email: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) Create(ctx context.Context, params CreateUserParams) (*User, error) {
|
||||
isActive := 0
|
||||
if params.IsActive {
|
||||
isActive = 1
|
||||
}
|
||||
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO users (email, password_hash, role, is_active) VALUES (?, ?, ?, ?)`,
|
||||
strings.TrimSpace(params.Email),
|
||||
params.PasswordHash,
|
||||
params.Role,
|
||||
isActive,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert user: %w", err)
|
||||
}
|
||||
|
||||
userID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load inserted user id: %w", err)
|
||||
}
|
||||
|
||||
return r.GetByID(ctx, userID)
|
||||
}
|
||||
|
||||
func (r *UserRepository) UpdateLastLoginAt(ctx context.Context, userID int64, loggedInAt time.Time) error {
|
||||
result, err := r.q.ExecContext(
|
||||
ctx,
|
||||
`UPDATE users SET last_login_at = ? WHERE id = ?`,
|
||||
formatTimestamp(loggedInAt),
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update user last_login_at: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read affected user rows: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanUser(scanner rowScanner) (*User, error) {
|
||||
var (
|
||||
user User
|
||||
role string
|
||||
isActive int
|
||||
createdAtRaw string
|
||||
updatedAtRaw string
|
||||
lastLoginRaw sql.NullString
|
||||
)
|
||||
|
||||
if err := scanner.Scan(
|
||||
&user.ID,
|
||||
&user.Email,
|
||||
&user.PasswordHash,
|
||||
&role,
|
||||
&isActive,
|
||||
&createdAtRaw,
|
||||
&updatedAtRaw,
|
||||
&lastLoginRaw,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdAt, err := parseTimestamp(createdAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse user created_at: %w", err)
|
||||
}
|
||||
|
||||
updatedAt, err := parseTimestamp(updatedAtRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse user updated_at: %w", err)
|
||||
}
|
||||
|
||||
lastLoginAt, err := parseNullableTimestamp(lastLoginRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse user last_login_at: %w", err)
|
||||
}
|
||||
|
||||
user.Role = UserRole(role)
|
||||
user.IsActive = isActive == 1
|
||||
user.CreatedAt = createdAt
|
||||
user.UpdatedAt = updatedAt
|
||||
user.LastLoginAt = lastLoginAt
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue