restructure project, add claudemd
This commit is contained in:
18
go/cmd/bundle/main.go
Normal file
18
go/cmd/bundle/main.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package main
|
||||
|
||||
// Thin CLI wrapper around kjol/bundler. The bundler wires its own Go-native
|
||||
// Solid JSX compiler (see bundler.Build), so this wrapper carries no build logic.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"kjol/bundler"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := bundler.Build(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
129
go/cmd/loc/main.go
Normal file
129
go/cmd/loc/main.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/hhatto/gocloc"
|
||||
)
|
||||
|
||||
func main() {
|
||||
out, err := exec.Command("git", "ls-files").Output()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "git ls-files: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
allFiles := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
|
||||
var files []string
|
||||
for _, f := range allFiles {
|
||||
f = strings.TrimSpace(f)
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
norm := filepath.ToSlash(f)
|
||||
if strings.HasPrefix(norm, "vendor/") || strings.Contains(norm, "/vendor/") {
|
||||
continue
|
||||
}
|
||||
files = append(files, f)
|
||||
}
|
||||
|
||||
opts := gocloc.NewClocOptions()
|
||||
langs := gocloc.NewDefinedLanguages()
|
||||
|
||||
// gocloc maps "TypeScript" instead of "ts" in its Exts table
|
||||
extAliases := map[string]string{
|
||||
"ts": "TypeScript",
|
||||
}
|
||||
|
||||
total := gocloc.NewLanguage("TOTAL", []string{}, [][]string{{"", ""}})
|
||||
languages := make(map[string]*gocloc.Language)
|
||||
clocFiles := make(map[string]*gocloc.ClocFile)
|
||||
|
||||
for _, file := range files {
|
||||
file = strings.TrimSpace(file)
|
||||
if file == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
ext := filepath.Ext(file)
|
||||
if ext == "" {
|
||||
continue
|
||||
}
|
||||
ext = ext[1:]
|
||||
|
||||
langName, ok := gocloc.Exts[ext]
|
||||
if !ok {
|
||||
langName, ok = extAliases[ext]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
def := langs.Langs[langName]
|
||||
if def == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
cf := gocloc.AnalyzeFile(file, def, opts)
|
||||
cf.Lang = langName
|
||||
clocFiles[file] = cf
|
||||
|
||||
if _, exists := languages[langName]; !exists {
|
||||
languages[langName] = gocloc.NewLanguage(def.Name, []string{}, [][]string{{"", ""}})
|
||||
}
|
||||
lang := languages[langName]
|
||||
lang.Files = append(lang.Files, file)
|
||||
lang.Code += cf.Code
|
||||
lang.Comments += cf.Comments
|
||||
lang.Blanks += cf.Blanks
|
||||
|
||||
total.Code += cf.Code
|
||||
total.Comments += cf.Comments
|
||||
total.Blanks += cf.Blanks
|
||||
}
|
||||
|
||||
type row struct {
|
||||
Name string
|
||||
Files int
|
||||
Code int32
|
||||
Comments int32
|
||||
Blanks int32
|
||||
}
|
||||
|
||||
var rows []row
|
||||
for name, lang := range languages {
|
||||
rows = append(rows, row{
|
||||
Name: name,
|
||||
Files: len(lang.Files),
|
||||
Code: lang.Code,
|
||||
Comments: lang.Comments,
|
||||
Blanks: lang.Blanks,
|
||||
})
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
return rows[i].Code > rows[j].Code
|
||||
})
|
||||
|
||||
divider := "-------------------------------------------------------------------------------"
|
||||
fmt.Println(divider)
|
||||
fmt.Printf("%-25s %10s %10s %10s %10s\n", "Language", "Files", "Code", "Comment", "Blank")
|
||||
fmt.Println(divider)
|
||||
for _, r := range rows {
|
||||
fmt.Printf("%-25s %10d %10d %10d %10d\n", r.Name, r.Files, r.Code, r.Comments, r.Blanks)
|
||||
}
|
||||
fmt.Println(divider)
|
||||
fmt.Printf("%-25s %10d %10d %10d %10d\n",
|
||||
"Total",
|
||||
len(clocFiles),
|
||||
total.Code,
|
||||
total.Comments,
|
||||
total.Blanks,
|
||||
)
|
||||
fmt.Println(divider)
|
||||
}
|
||||
380
go/cmd/migrate/database.go
Normal file
380
go/cmd/migrate/database.go
Normal file
@@ -0,0 +1,380 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
const (
|
||||
nilVersion int = -1
|
||||
migrationsTable = "schema_migrations"
|
||||
advisoryLockIDSalt uint = 1486364155
|
||||
)
|
||||
|
||||
type migrateDB struct {
|
||||
conn *sql.Conn
|
||||
db *sql.DB
|
||||
schemaName string
|
||||
dbName string
|
||||
lockID string
|
||||
isLocked bool
|
||||
}
|
||||
|
||||
func openDatabase(connStr string, schemaName string) (*migrateDB, error) {
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
conn, err := db.Conn(context.Background())
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to acquire connection: %w", err)
|
||||
}
|
||||
|
||||
mdb := &migrateDB{
|
||||
conn: conn,
|
||||
db: db,
|
||||
}
|
||||
|
||||
// get actual database name
|
||||
if err := conn.QueryRowContext(context.Background(), "SELECT current_database()").Scan(&mdb.dbName); err != nil {
|
||||
mdb.close()
|
||||
return nil, fmt.Errorf("failed to get database name: %w", err)
|
||||
}
|
||||
|
||||
// determine schema name
|
||||
if schemaName != "" {
|
||||
mdb.schemaName = schemaName
|
||||
} else {
|
||||
if err := conn.QueryRowContext(context.Background(), "SELECT current_schema()").Scan(&mdb.schemaName); err != nil {
|
||||
mdb.close()
|
||||
return nil, fmt.Errorf("failed to get schema name: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
mdb.lockID = generateAdvisoryLockID(mdb.dbName, mdb.schemaName)
|
||||
|
||||
// ensure the schema_migrations table exists
|
||||
if err := mdb.ensureVersionTable(); err != nil {
|
||||
mdb.close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return mdb, nil
|
||||
}
|
||||
|
||||
func (d *migrateDB) close() error {
|
||||
var connErr, dbErr error
|
||||
if d.conn != nil {
|
||||
connErr = d.conn.Close()
|
||||
}
|
||||
if d.db != nil {
|
||||
dbErr = d.db.Close()
|
||||
}
|
||||
if connErr != nil {
|
||||
return connErr
|
||||
}
|
||||
return dbErr
|
||||
}
|
||||
|
||||
func (d *migrateDB) ensureVersionTable() error {
|
||||
if err := d.lock(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer d.unlock()
|
||||
|
||||
// check if table already exists
|
||||
var count int
|
||||
query := `SELECT COUNT(1) FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2 LIMIT 1`
|
||||
if err := d.conn.QueryRowContext(context.Background(), query, d.schemaName, migrationsTable).Scan(&count); err != nil {
|
||||
return fmt.Errorf("failed to check for migrations table: %w", err)
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// create the table
|
||||
createQuery := `CREATE TABLE IF NOT EXISTS ` +
|
||||
pq.QuoteIdentifier(d.schemaName) + `.` + pq.QuoteIdentifier(migrationsTable) +
|
||||
` (version bigint NOT NULL PRIMARY KEY, dirty boolean NOT NULL)`
|
||||
|
||||
if _, err := d.conn.ExecContext(context.Background(), createQuery); err != nil {
|
||||
return fmt.Errorf("failed to create migrations table: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateAdvisoryLockID replicates golang-migrate's lock ID generation.
|
||||
// Internally: strings.Join(append([]string{schemaName, tableName}, databaseName), "\x00")
|
||||
// Result: "schemaName\x00tableName\x00databaseName"
|
||||
// Then: CRC32(result) * 1486364155
|
||||
func generateAdvisoryLockID(dbName, schemaName string) string {
|
||||
combined := strings.Join([]string{schemaName, migrationsTable, dbName}, "\x00")
|
||||
sum := crc32.ChecksumIEEE([]byte(combined))
|
||||
sum = sum * uint32(advisoryLockIDSalt)
|
||||
return fmt.Sprint(sum)
|
||||
}
|
||||
|
||||
func (d *migrateDB) lock() error {
|
||||
if d.isLocked {
|
||||
return fmt.Errorf("database already locked")
|
||||
}
|
||||
|
||||
query := `SELECT pg_advisory_lock($1)`
|
||||
if _, err := d.conn.ExecContext(context.Background(), query, d.lockID); err != nil {
|
||||
return fmt.Errorf("failed to acquire advisory lock: %w", err)
|
||||
}
|
||||
|
||||
d.isLocked = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *migrateDB) unlock() error {
|
||||
if !d.isLocked {
|
||||
return nil
|
||||
}
|
||||
|
||||
query := `SELECT pg_advisory_unlock($1)`
|
||||
if _, err := d.conn.ExecContext(context.Background(), query, d.lockID); err != nil {
|
||||
return fmt.Errorf("failed to release advisory lock: %w", err)
|
||||
}
|
||||
|
||||
d.isLocked = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *migrateDB) version() (int, bool, error) {
|
||||
query := `SELECT version, dirty FROM ` +
|
||||
pq.QuoteIdentifier(d.schemaName) + `.` + pq.QuoteIdentifier(migrationsTable) +
|
||||
` LIMIT 1`
|
||||
|
||||
var version int
|
||||
var dirty bool
|
||||
err := d.conn.QueryRowContext(context.Background(), query).Scan(&version, &dirty)
|
||||
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
return nilVersion, false, nil
|
||||
case err != nil:
|
||||
if e, ok := err.(*pq.Error); ok {
|
||||
if e.Code.Name() == "undefined_table" {
|
||||
return nilVersion, false, nil
|
||||
}
|
||||
}
|
||||
return 0, false, fmt.Errorf("failed to get migration version: %w", err)
|
||||
default:
|
||||
return version, dirty, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (d *migrateDB) setVersion(version int, dirty bool) error {
|
||||
tx, err := d.conn.BeginTx(context.Background(), &sql.TxOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
|
||||
truncateQuery := `TRUNCATE ` +
|
||||
pq.QuoteIdentifier(d.schemaName) + `.` + pq.QuoteIdentifier(migrationsTable)
|
||||
|
||||
if _, err := tx.Exec(truncateQuery); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed to truncate migrations table: %w", err)
|
||||
}
|
||||
|
||||
// re-write the schema version for nil dirty versions to prevent
|
||||
// empty schema version for failed down migration on the first migration
|
||||
if version >= 0 || (version == nilVersion && dirty) {
|
||||
insertQuery := `INSERT INTO ` +
|
||||
pq.QuoteIdentifier(d.schemaName) + `.` + pq.QuoteIdentifier(migrationsTable) +
|
||||
` (version, dirty) VALUES ($1, $2)`
|
||||
|
||||
if _, err := tx.Exec(insertQuery, version, dirty); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed to insert migration version: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("failed to commit version update: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *migrateDB) run(filePath string) error {
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read migration file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
query := string(content)
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := d.conn.ExecContext(context.Background(), query); err != nil {
|
||||
if pgErr, ok := err.(*pq.Error); ok {
|
||||
message := fmt.Sprintf("migration failed: %s", pgErr.Message)
|
||||
if pgErr.Position != "" {
|
||||
if pos, parseErr := strconv.ParseUint(pgErr.Position, 10, 64); parseErr == nil {
|
||||
line, col, ok := computeLineFromPos(query, int(pos))
|
||||
if ok {
|
||||
message = fmt.Sprintf("%s (line %d, column %d)", message, line, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
if pgErr.Detail != "" {
|
||||
message = fmt.Sprintf("%s, %s", message, pgErr.Detail)
|
||||
}
|
||||
return fmt.Errorf("%s", message)
|
||||
}
|
||||
return fmt.Errorf("migration failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *migrateDB) drop() error {
|
||||
// Drop tables.
|
||||
tableQuery := `SELECT table_name FROM information_schema.tables WHERE table_schema=$1 AND table_type='BASE TABLE'`
|
||||
rows, err := d.conn.QueryContext(context.Background(), tableQuery, d.schemaName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to query tables: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tableNames []string
|
||||
for rows.Next() {
|
||||
var tableName string
|
||||
if err := rows.Scan(&tableName); err != nil {
|
||||
return fmt.Errorf("failed to scan table name: %w", err)
|
||||
}
|
||||
if len(tableName) > 0 {
|
||||
tableNames = append(tableNames, tableName)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("failed to iterate tables: %w", err)
|
||||
}
|
||||
|
||||
for _, t := range tableNames {
|
||||
dropQuery := `DROP TABLE IF EXISTS ` + pq.QuoteIdentifier(d.schemaName) + `.` + pq.QuoteIdentifier(t) + ` CASCADE`
|
||||
if _, err := d.conn.ExecContext(context.Background(), dropQuery); err != nil {
|
||||
return fmt.Errorf("failed to drop table %s: %w", t, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Drop views.
|
||||
viewQuery := `SELECT table_name FROM information_schema.views WHERE table_schema=$1`
|
||||
viewRows, err := d.conn.QueryContext(context.Background(), viewQuery, d.schemaName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to query views: %w", err)
|
||||
}
|
||||
defer viewRows.Close()
|
||||
|
||||
var viewNames []string
|
||||
for viewRows.Next() {
|
||||
var viewName string
|
||||
if err := viewRows.Scan(&viewName); err != nil {
|
||||
return fmt.Errorf("failed to scan view name: %w", err)
|
||||
}
|
||||
viewNames = append(viewNames, viewName)
|
||||
}
|
||||
if err := viewRows.Err(); err != nil {
|
||||
return fmt.Errorf("failed to iterate views: %w", err)
|
||||
}
|
||||
|
||||
for _, v := range viewNames {
|
||||
dropQuery := `DROP VIEW IF EXISTS ` + pq.QuoteIdentifier(d.schemaName) + `.` + pq.QuoteIdentifier(v) + ` CASCADE`
|
||||
if _, err := d.conn.ExecContext(context.Background(), dropQuery); err != nil {
|
||||
return fmt.Errorf("failed to drop view %s: %w", v, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Drop custom types (enums, composites, domains, ranges). typtype 'b' (base)
|
||||
// already excludes Postgres' auto-generated array types, so no further
|
||||
// filtering by name is needed.
|
||||
typeQuery := `
|
||||
SELECT t.typname
|
||||
FROM pg_type t
|
||||
JOIN pg_namespace n ON n.oid = t.typnamespace
|
||||
WHERE n.nspname = $1
|
||||
AND t.typtype IN ('e', 'c', 'd', 'r')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM pg_class c
|
||||
WHERE c.reltype = t.oid AND c.relkind IN ('r', 'v', 'm', 'f', 'p')
|
||||
)
|
||||
`
|
||||
typeRows, err := d.conn.QueryContext(context.Background(), typeQuery, d.schemaName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to query types: %w", err)
|
||||
}
|
||||
defer typeRows.Close()
|
||||
|
||||
var typeNames []string
|
||||
for typeRows.Next() {
|
||||
var typeName string
|
||||
if err := typeRows.Scan(&typeName); err != nil {
|
||||
return fmt.Errorf("failed to scan type name: %w", err)
|
||||
}
|
||||
typeNames = append(typeNames, typeName)
|
||||
}
|
||||
if err := typeRows.Err(); err != nil {
|
||||
return fmt.Errorf("failed to iterate types: %w", err)
|
||||
}
|
||||
|
||||
for _, tn := range typeNames {
|
||||
dropQuery := `DROP TYPE IF EXISTS ` + pq.QuoteIdentifier(d.schemaName) + `.` + pq.QuoteIdentifier(tn) + ` CASCADE`
|
||||
if _, err := d.conn.ExecContext(context.Background(), dropQuery); err != nil {
|
||||
return fmt.Errorf("failed to drop type %s: %w", tn, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func computeLineFromPos(s string, pos int) (line uint, col uint, ok bool) {
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
runes := []rune(s)
|
||||
if pos > len(runes) {
|
||||
return 0, 0, false
|
||||
}
|
||||
sel := runes[:pos]
|
||||
line = uint(runesCount(sel, '\n') + 1)
|
||||
col = uint(pos - 1 - runesLastIndex(sel, '\n'))
|
||||
return line, col, true
|
||||
}
|
||||
|
||||
func runesCount(input []rune, target rune) int {
|
||||
var count int
|
||||
for _, r := range input {
|
||||
if r == target {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func runesLastIndex(input []rune, target rune) int {
|
||||
for i := len(input) - 1; i >= 0; i-- {
|
||||
if input[i] == target {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
260
go/cmd/migrate/engine.go
Normal file
260
go/cmd/migrate/engine.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
var (
|
||||
errNoChange = errors.New("no change")
|
||||
)
|
||||
|
||||
type engine struct {
|
||||
source *migrationSource
|
||||
db *migrateDB
|
||||
}
|
||||
|
||||
func newEngine(migrationsDir string, connStr string, schemaName string) (*engine, error) {
|
||||
source, err := newMigrationSource(migrationsDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db, err := openDatabase(connStr, schemaName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &engine{source: source, db: db}, nil
|
||||
}
|
||||
|
||||
func (e *engine) close() error {
|
||||
return e.db.close()
|
||||
}
|
||||
|
||||
func (e *engine) up() error {
|
||||
if err := e.db.lock(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer e.db.unlock()
|
||||
|
||||
curVersion, dirty, err := e.db.version()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dirty {
|
||||
return fmt.Errorf("Dirty database version %d. Fix and force version.", curVersion)
|
||||
}
|
||||
|
||||
// determine where to start
|
||||
var version uint
|
||||
if curVersion == nilVersion {
|
||||
first, err := e.source.first()
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return errNoChange
|
||||
}
|
||||
return err
|
||||
}
|
||||
version = first
|
||||
} else {
|
||||
next, err := e.source.next(uint(curVersion))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return errNoChange
|
||||
}
|
||||
return err
|
||||
}
|
||||
version = next
|
||||
}
|
||||
|
||||
// apply from starting version forward
|
||||
for {
|
||||
if err := e.applyUp(version); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
next, err := e.source.next(version)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil // done, applied all
|
||||
}
|
||||
return err
|
||||
}
|
||||
version = next
|
||||
}
|
||||
}
|
||||
|
||||
func (e *engine) down() error {
|
||||
if err := e.db.lock(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer e.db.unlock()
|
||||
|
||||
curVersion, dirty, err := e.db.version()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dirty {
|
||||
return fmt.Errorf("Dirty database version %d. Fix and force version.", curVersion)
|
||||
}
|
||||
if curVersion == nilVersion {
|
||||
return errNoChange
|
||||
}
|
||||
|
||||
// determine target version after rollback
|
||||
targetVersion := nilVersion
|
||||
prev, err := e.source.prev(uint(curVersion))
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
// at the first migration, target is nilVersion (-1)
|
||||
} else {
|
||||
targetVersion = int(prev)
|
||||
}
|
||||
|
||||
return e.applyDown(uint(curVersion), targetVersion)
|
||||
}
|
||||
|
||||
func (e *engine) goTo(target uint) error {
|
||||
if err := e.db.lock(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer e.db.unlock()
|
||||
|
||||
curVersion, dirty, err := e.db.version()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dirty {
|
||||
return fmt.Errorf("Dirty database version %d. Fix and force version.", curVersion)
|
||||
}
|
||||
|
||||
if !e.source.versionExists(target) {
|
||||
return fmt.Errorf("version %d not found in migration source", target)
|
||||
}
|
||||
|
||||
intTarget := int(target)
|
||||
if intTarget == curVersion {
|
||||
return errNoChange
|
||||
}
|
||||
|
||||
if intTarget > curVersion {
|
||||
// going up
|
||||
var version uint
|
||||
if curVersion == nilVersion {
|
||||
first, err := e.source.first()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
version = first
|
||||
} else {
|
||||
next, err := e.source.next(uint(curVersion))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
version = next
|
||||
}
|
||||
|
||||
for {
|
||||
if err := e.applyUp(version); err != nil {
|
||||
return err
|
||||
}
|
||||
if version == target {
|
||||
return nil
|
||||
}
|
||||
next, err := e.source.next(version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
version = next
|
||||
}
|
||||
}
|
||||
|
||||
// going down
|
||||
version := uint(curVersion)
|
||||
for int(version) > intTarget {
|
||||
prev, err := e.source.prev(version)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
// we're at the first version, can't go lower
|
||||
return fmt.Errorf("cannot migrate down from version %d to %d", version, target)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := e.applyDown(version, int(prev)); err != nil {
|
||||
return err
|
||||
}
|
||||
version = prev
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *engine) force(version int) error {
|
||||
if err := e.db.lock(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer e.db.unlock()
|
||||
|
||||
return e.db.setVersion(version, false)
|
||||
}
|
||||
|
||||
func (e *engine) dropAll() error {
|
||||
if err := e.db.lock(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer e.db.unlock()
|
||||
|
||||
return e.db.drop()
|
||||
}
|
||||
|
||||
// applyUp applies a single up migration for the given version.
|
||||
func (e *engine) applyUp(version uint) error {
|
||||
// mark dirty before execution
|
||||
if err := e.db.setVersion(int(version), true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filePath, err := e.source.readUp(version)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
// no up file for this version, just set clean
|
||||
return e.db.setVersion(int(version), false)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := e.db.run(filePath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// mark clean after successful execution
|
||||
return e.db.setVersion(int(version), false)
|
||||
}
|
||||
|
||||
// applyDown applies a single down migration from fromVersion, setting targetVersion afterward.
|
||||
func (e *engine) applyDown(fromVersion uint, targetVersion int) error {
|
||||
// mark dirty with target version before execution
|
||||
if err := e.db.setVersion(targetVersion, true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filePath, err := e.source.readDown(fromVersion)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
// no down file for this version, just set clean
|
||||
return e.db.setVersion(targetVersion, false)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := e.db.run(filePath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// mark clean after successful execution
|
||||
return e.db.setVersion(targetVersion, false)
|
||||
}
|
||||
273
go/cmd/migrate/main.go
Normal file
273
go/cmd/migrate/main.go
Normal file
@@ -0,0 +1,273 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kjol/config"
|
||||
"kjol/dbutil"
|
||||
|
||||
_ "time/tzdata"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
func invalidInput() {
|
||||
fmt.Println("Usage: [up, down, drop, goto {V}, new {migration name}]")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
const (
|
||||
defaultTimeFormat = "20060102150405"
|
||||
defaultTimezone = "UTC"
|
||||
)
|
||||
|
||||
var (
|
||||
errInvalidSequenceWidth = errors.New("Digits must be positive")
|
||||
errIncompatibleSeqAndFormat = errors.New("The seq and format options are mutually exclusive")
|
||||
errInvalidTimeFormat = errors.New("Time format may not be empty")
|
||||
)
|
||||
|
||||
func createFile(filename string) error {
|
||||
// create exclusive (fails if file already exists)
|
||||
// os.Create() specifies 0666 as the FileMode, so we're doing the same
|
||||
f, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0666)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
func nextSeqVersion(matches []string, seqDigits int) (string, error) {
|
||||
if seqDigits <= 0 {
|
||||
return "", errInvalidSequenceWidth
|
||||
}
|
||||
|
||||
nextSeq := uint64(1)
|
||||
|
||||
if len(matches) > 0 {
|
||||
filename := matches[len(matches)-1]
|
||||
matchSeqStr := filepath.Base(filename)
|
||||
idx := strings.Index(matchSeqStr, "_")
|
||||
|
||||
if idx < 1 { // Using 1 instead of 0 since there should be at least 1 digit
|
||||
return "", fmt.Errorf("Malformed migration filename: %s", filename)
|
||||
}
|
||||
|
||||
var err error
|
||||
matchSeqStr = matchSeqStr[0:idx]
|
||||
nextSeq, err = strconv.ParseUint(matchSeqStr, 10, 64)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nextSeq++
|
||||
}
|
||||
|
||||
version := fmt.Sprintf("%0[2]*[1]d", nextSeq, seqDigits)
|
||||
|
||||
if len(version) > seqDigits {
|
||||
return "", fmt.Errorf("Next sequence number %s too large. At most %d digits are allowed", version, seqDigits)
|
||||
}
|
||||
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func timeVersion(startTime time.Time, format string) (version string, err error) {
|
||||
switch format {
|
||||
case "":
|
||||
err = errInvalidTimeFormat
|
||||
case "unix":
|
||||
version = strconv.FormatInt(startTime.Unix(), 10)
|
||||
case "unixNano":
|
||||
version = strconv.FormatInt(startTime.UnixNano(), 10)
|
||||
default:
|
||||
version = startTime.Format(format)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func newCmd(dir string, startTime time.Time, format string, name string, ext string, seq bool, seqDigits int, print bool) error {
|
||||
if seq && format != defaultTimeFormat {
|
||||
return errIncompatibleSeqAndFormat
|
||||
}
|
||||
|
||||
var version string
|
||||
var err error
|
||||
|
||||
dir = filepath.Clean(dir)
|
||||
ext = "." + strings.TrimPrefix(ext, ".")
|
||||
|
||||
if seq {
|
||||
matches, err := filepath.Glob(filepath.Join(dir, "*"+ext))
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
version, err = nextSeqVersion(matches, seqDigits)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
version, err = timeVersion(startTime, format)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
versionGlob := filepath.Join(dir, version+"_*"+ext)
|
||||
matches, err := filepath.Glob(versionGlob)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(matches) > 0 {
|
||||
return fmt.Errorf("duplicate migration version: %s", version)
|
||||
}
|
||||
|
||||
if err = os.MkdirAll(dir, os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, direction := range []string{"up", "down"} {
|
||||
basename := fmt.Sprintf("%s_%s.%s%s", version, name, direction, ext)
|
||||
filename := filepath.Join(dir, basename)
|
||||
|
||||
if err = createFile(filename); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if print {
|
||||
absPath, _ := filepath.Abs(filename)
|
||||
log.Println(absPath)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
envfile := flag.String("env-file", "", "Load environment variables from this file instead of the implicit ./.env. Real environment variables always take precedence.")
|
||||
dir := flag.String("dir", "./migrations", "Directory containing migration files.")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
var dbc struct {
|
||||
Username string `env:"DATABASE_USERNAME"`
|
||||
Password string `env:"DATABASE_PASSWORD"`
|
||||
Host string `env:"DATABASE_HOST"`
|
||||
Port int `env:"DATABASE_PORT"`
|
||||
Name string `env:"DATABASE_NAME"`
|
||||
Schema string `env:"DATABASE_SCHEMA"`
|
||||
SSLMode string `env:"DATABASE_SSL_MODE"`
|
||||
}
|
||||
if err := config.Load(*envfile, &dbc); err != nil {
|
||||
fmt.Println(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
connectionString := dbutil.BuildConnectionString(dbutil.ConnConfig{
|
||||
Username: dbc.Username,
|
||||
Password: dbc.Password,
|
||||
Host: dbc.Host,
|
||||
Port: dbc.Port,
|
||||
Name: dbc.Name,
|
||||
Schema: dbc.Schema,
|
||||
SSLMode: dbc.SSLMode,
|
||||
})
|
||||
|
||||
// parse CLI args and do actions
|
||||
args := flag.Args()
|
||||
|
||||
if len(args) < 1 {
|
||||
invalidInput()
|
||||
}
|
||||
|
||||
// handle "new" command early — it doesn't need a database connection
|
||||
if args[0] == "new" {
|
||||
if len(args) < 2 {
|
||||
fmt.Println("Please provide a name for the new migration.")
|
||||
os.Exit(1)
|
||||
}
|
||||
newCmd(*dir, time.Now(), defaultTimeFormat, args[1], "sql", true, 7, true)
|
||||
return
|
||||
}
|
||||
|
||||
eng, err := newEngine(*dir, connectionString, dbc.Schema)
|
||||
if err != nil {
|
||||
fmt.Println(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
defer eng.close()
|
||||
|
||||
migrateNum := 0
|
||||
|
||||
if len(args) >= 2 {
|
||||
var parseErr error
|
||||
migrateNum, parseErr = strconv.Atoi(args[1])
|
||||
if parseErr != nil {
|
||||
fmt.Println("Please provide a valid migration number.")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "up":
|
||||
if err := eng.up(); err != nil {
|
||||
if errors.Is(err, errNoChange) {
|
||||
fmt.Println("No change")
|
||||
os.Exit(0)
|
||||
}
|
||||
fmt.Println(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Database successfully migrated to latest version")
|
||||
|
||||
case "down":
|
||||
if err := eng.down(); err != nil {
|
||||
if errors.Is(err, errNoChange) {
|
||||
fmt.Println("No change")
|
||||
os.Exit(0)
|
||||
}
|
||||
fmt.Println(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Database successfully migrated to previous version")
|
||||
|
||||
case "goto":
|
||||
if err := eng.goTo(uint(migrateNum)); err != nil {
|
||||
if errors.Is(err, errNoChange) {
|
||||
fmt.Println("No change")
|
||||
os.Exit(0)
|
||||
}
|
||||
fmt.Println(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Database successfully migrated to version `%d`\n", migrateNum)
|
||||
|
||||
case "drop":
|
||||
if err := eng.dropAll(); err != nil {
|
||||
fmt.Println(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Database tables, views, and types successfully dropped")
|
||||
|
||||
default:
|
||||
invalidInput()
|
||||
}
|
||||
}
|
||||
151
go/cmd/migrate/source.go
Normal file
151
go/cmd/migrate/source.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type direction string
|
||||
|
||||
const (
|
||||
dirUp direction = "up"
|
||||
dirDown direction = "down"
|
||||
)
|
||||
|
||||
type migrationFile struct {
|
||||
Version uint
|
||||
Identifier string
|
||||
Direction direction
|
||||
Filename string // e.g. "0000001_INITIAL_CREATE.up.sql"
|
||||
}
|
||||
|
||||
type migrationSource struct {
|
||||
dir string
|
||||
index []uint // sorted unique version numbers
|
||||
migrations map[uint]map[direction]*migrationFile // version -> direction -> file
|
||||
}
|
||||
|
||||
// matches: 0000001_INITIAL_CREATE.up.sql
|
||||
var migrationRegex = regexp.MustCompile(`^([0-9]+)_(.*)\.(up|down)\.(.*)$`)
|
||||
|
||||
func newMigrationSource(dir string) (*migrationSource, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read migrations directory: %w", err)
|
||||
}
|
||||
|
||||
s := &migrationSource{
|
||||
dir: dir,
|
||||
migrations: make(map[uint]map[direction]*migrationFile),
|
||||
}
|
||||
|
||||
versionSet := make(map[uint]struct{})
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
m := migrationRegex.FindStringSubmatch(name)
|
||||
if len(m) != 5 {
|
||||
// skip non-matching filenames (same as library)
|
||||
continue
|
||||
}
|
||||
|
||||
versionUint64, err := strconv.ParseUint(m[1], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
version := uint(versionUint64)
|
||||
dir := direction(m[3])
|
||||
|
||||
mf := &migrationFile{
|
||||
Version: version,
|
||||
Identifier: m[2],
|
||||
Direction: dir,
|
||||
Filename: name,
|
||||
}
|
||||
|
||||
if _, ok := s.migrations[version]; !ok {
|
||||
s.migrations[version] = make(map[direction]*migrationFile)
|
||||
}
|
||||
|
||||
if _, exists := s.migrations[version][dir]; exists {
|
||||
return nil, fmt.Errorf("duplicate migration version %d direction %s", version, dir)
|
||||
}
|
||||
|
||||
s.migrations[version][dir] = mf
|
||||
versionSet[version] = struct{}{}
|
||||
}
|
||||
|
||||
// build sorted index
|
||||
s.index = make([]uint, 0, len(versionSet))
|
||||
for v := range versionSet {
|
||||
s.index = append(s.index, v)
|
||||
}
|
||||
sort.Slice(s.index, func(i, j int) bool {
|
||||
return s.index[i] < s.index[j]
|
||||
})
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *migrationSource) first() (uint, error) {
|
||||
if len(s.index) == 0 {
|
||||
return 0, os.ErrNotExist
|
||||
}
|
||||
return s.index[0], nil
|
||||
}
|
||||
|
||||
func (s *migrationSource) next(version uint) (uint, error) {
|
||||
pos := sort.Search(len(s.index), func(i int) bool {
|
||||
return s.index[i] > version
|
||||
})
|
||||
if pos >= len(s.index) {
|
||||
return 0, os.ErrNotExist
|
||||
}
|
||||
return s.index[pos], nil
|
||||
}
|
||||
|
||||
func (s *migrationSource) prev(version uint) (uint, error) {
|
||||
pos := sort.Search(len(s.index), func(i int) bool {
|
||||
return s.index[i] >= version
|
||||
})
|
||||
// pos is the index of version (or where it would be inserted)
|
||||
// we want the one before it
|
||||
if pos <= 0 {
|
||||
return 0, os.ErrNotExist
|
||||
}
|
||||
return s.index[pos-1], nil
|
||||
}
|
||||
|
||||
func (s *migrationSource) readUp(version uint) (string, error) {
|
||||
if dirs, ok := s.migrations[version]; ok {
|
||||
if mf, ok := dirs[dirUp]; ok {
|
||||
return filepath.Join(s.dir, mf.Filename), nil
|
||||
}
|
||||
}
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
|
||||
func (s *migrationSource) readDown(version uint) (string, error) {
|
||||
if dirs, ok := s.migrations[version]; ok {
|
||||
if mf, ok := dirs[dirDown]; ok {
|
||||
return filepath.Join(s.dir, mf.Filename), nil
|
||||
}
|
||||
}
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
|
||||
func (s *migrationSource) versionExists(version uint) bool {
|
||||
pos := sort.Search(len(s.index), func(i int) bool {
|
||||
return s.index[i] >= version
|
||||
})
|
||||
return pos < len(s.index) && s.index[pos] == version
|
||||
}
|
||||
23
go/cmd/passgen/main.go
Normal file
23
go/cmd/passgen/main.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"kjol/appenv"
|
||||
"kjol/security"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) == 2 {
|
||||
passHash, _ := security.HashPassword(os.Args[1])
|
||||
|
||||
println(passHash)
|
||||
} else {
|
||||
println("Please input a password as first program argument")
|
||||
}
|
||||
|
||||
if appenv.Environment == appenv.EnvTypeDevelopment {
|
||||
log.Printf("log message")
|
||||
}
|
||||
}
|
||||
150
go/cmd/typecheck/main.go
Normal file
150
go/cmd/typecheck/main.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package main
|
||||
|
||||
// Frontend TypeScript checker. Runs tsc in noEmit mode using
|
||||
// tsconfig.json. Requires node on PATH; downloads the pinned TypeScript
|
||||
// release on first run (no npm).
|
||||
//
|
||||
// go run ./cmd/typecheck
|
||||
// go run ./cmd/typecheck -p tsconfig.json
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const typescriptVersion = "5.8.3"
|
||||
|
||||
func main() {
|
||||
tsconfig := flag.String("p", "tsconfig.json", "path to tsconfig.json")
|
||||
flag.Parse()
|
||||
|
||||
if err := runTypecheck(*tsconfig); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Typecheck failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runTypecheck(tsconfig string) error {
|
||||
node, err := exec.LookPath("node")
|
||||
if err != nil {
|
||||
return fmt.Errorf("node not found on PATH (required to run tsc): %w", err)
|
||||
}
|
||||
|
||||
tsc, err := ensureTypeScript()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(tsconfig); err != nil {
|
||||
return fmt.Errorf("tsconfig not found: %s", tsconfig)
|
||||
}
|
||||
|
||||
fmt.Printf("Typechecking with TypeScript %s...\n", typescriptVersion)
|
||||
cmd := exec.Command(node, tsc, "--noEmit", "-p", tsconfig)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("No type errors.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureTypeScript() (string, error) {
|
||||
root, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cacheDir := filepath.Join(root, "tools", ".cache", "typescript", typescriptVersion)
|
||||
tscPath := filepath.Join(cacheDir, "package", "lib", "tsc.js")
|
||||
if _, err := os.Stat(tscPath); err == nil {
|
||||
return tscPath, nil
|
||||
}
|
||||
|
||||
fmt.Printf("Downloading TypeScript %s...\n", typescriptVersion)
|
||||
if err := downloadTypeScript(cacheDir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(tscPath); err != nil {
|
||||
return "", fmt.Errorf("tsc not found after download: %s", tscPath)
|
||||
}
|
||||
return tscPath, nil
|
||||
}
|
||||
|
||||
func downloadTypeScript(destDir string) error {
|
||||
url := fmt.Sprintf("https://registry.npmjs.org/typescript/-/typescript-%s.tgz", typescriptVersion)
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download typescript: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download typescript: HTTP %s", resp.Status)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return extractTGZ(resp.Body, destDir)
|
||||
}
|
||||
|
||||
func extractTGZ(r io.Reader, destDir string) error {
|
||||
gz, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read typescript archive: %w", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
tr := tar.NewReader(gz)
|
||||
cleanDest := filepath.Clean(destDir)
|
||||
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read typescript archive: %w", err)
|
||||
}
|
||||
|
||||
target := filepath.Join(destDir, filepath.FromSlash(hdr.Name))
|
||||
cleanTarget := filepath.Clean(target)
|
||||
if cleanTarget != cleanDest && !strings.HasPrefix(cleanTarget, cleanDest+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("invalid archive path: %s", hdr.Name)
|
||||
}
|
||||
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode)&0o777|0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(f, tr); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user