diff --git a/.gitignore b/.gitignore index 03c26dc..455a32b 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,7 @@ docker/compose/config/test.xml docker/compose/tls/* !docker/compose/tls/placeholder +e2e/cert.pem +e2e/key.pem + dist/ diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 242617a..e0ceec0 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -4,6 +4,15 @@ package e2e import ( "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + mrand "math/rand" + "net" "os" "sync" "testing" @@ -12,12 +21,16 @@ import ( "github.com/adwski/vidi/internal/app/processor" "github.com/adwski/vidi/internal/app/streamer" "github.com/adwski/vidi/internal/app/uploader" - "github.com/adwski/vidi/internal/app/video" - "github.com/adwski/vidi/internal/app/user" + "github.com/adwski/vidi/internal/app/video" ) func TestMain(m *testing.M) { + if err := generateCertAndKey("localhost"); err != nil { + fmt.Println(err) + os.Exit(1) + } + var ( wg = &sync.WaitGroup{} ctx, cancel = context.WithCancel(context.Background()) @@ -54,3 +67,67 @@ func TestMain(m *testing.M) { os.Exit(code) }() } + +const ( + caOrg = "VIDItest" + caCountry = "RU" + caValidYears = 10 + + privateKeyRSALen = 4096 + + certPath = "cert.pem" + keyPath = "key.pem" +) + +var ( + caSubjectKeyIdentifier = []byte{1, 2, 3, 4, 6} +) + +func generateCertAndKey(cn string) error { + key, err := rsa.GenerateKey(rand.Reader, privateKeyRSALen) + if err != nil { + return fmt.Errorf("cannot generate rsa private key: %w", err) + } + ca := getCA(cn) + cert, err := x509.CreateCertificate(rand.Reader, ca, ca, &key.PublicKey, key) + if err != nil { + return fmt.Errorf("cannot create x509 certificate: %w", err) + } + keyPem := pem.EncodeToMemory( + &pem.Block{ + Type: "PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }, + ) + certPem := pem.EncodeToMemory( + &pem.Block{ + Type: "CERTIFICATE", + Bytes: cert, + }, + ) + if err = os.WriteFile(keyPath, keyPem, 0600); err != nil { + return fmt.Errorf("cannot write private key to file: %w", err) + } + if err = os.WriteFile(certPath, certPem, 0600); err != nil { + return fmt.Errorf("cannot write cert to file: %w", err) + } + return nil +} + +func getCA(cn string) *x509.Certificate { + return &x509.Certificate{ + SerialNumber: big.NewInt(mrand.Int63()), + Subject: pkix.Name{ + Country: []string{caCountry}, + Organization: []string{caOrg}, + CommonName: cn, + }, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, //nolint:gomnd // ip addr + DNSNames: []string{cn}, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(caValidYears, 0, 0), + SubjectKeyId: caSubjectKeyIdentifier, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, + KeyUsage: x509.KeyUsageDigitalSignature, + } +} diff --git a/e2e/uploader.yaml b/e2e/uploader.yaml index b9b6326..0800848 100644 --- a/e2e/uploader.yaml +++ b/e2e/uploader.yaml @@ -3,7 +3,7 @@ log: server: http: address: ":18083" - max_body_size: 5000000 + max_body_size: 11000000 api: prefix: /upload redis: diff --git a/e2e/videoapi.yaml b/e2e/videoapi.yaml index 4eabb7a..dbf9174 100644 --- a/e2e/videoapi.yaml +++ b/e2e/videoapi.yaml @@ -6,6 +6,10 @@ server: grpc: address: ":18092" svc_address: ":18093" + tls_userside_enable: true + tls: + key: key.pem + cert: cert.pem api: prefix: /api database: diff --git a/e2e/vidit_test.go b/e2e/vidit_test.go new file mode 100644 index 0000000..e98b276 --- /dev/null +++ b/e2e/vidit_test.go @@ -0,0 +1,403 @@ +package e2e + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strconv" + "sync" + "testing" + "time" + + "github.com/adwski/vidi/internal/tool" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/exp/teatest" + "github.com/labstack/gommon/random" + "github.com/stretchr/testify/require" +) + +const ( + VIDITe2eUserTmpl = "vidittestuser" + VIDITe2ePassword = "vidittestpassword" + + testRCFG = `{ + "user_api_url": "http://localhost:18081/api/user", + "video_api_url": "localhost:18092", + "vidi_ca": "%s" +}` +) + +var ( + VIDITe2eUser string +) + +func init() { + VIDITe2eUser = VIDITe2eUserTmpl + strconv.Itoa(int(time.Now().Unix())) +} + +func TestVidit_MainFlow(t *testing.T) { + // -------------------------------------------------------------------------------------- + // Prepare remote config and serve it + b, err := os.ReadFile("cert.pem") + require.NoError(t, err) + + remoteConfig := fmt.Sprintf(testRCFG, base64.StdEncoding.EncodeToString(b)) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, errW := w.Write([]byte(remoteConfig)) + require.NoError(t, errW) + })) + defer srv.Close() + + // -------------------------------------------------------------------------------------- + // Init tool + homeDir := t.TempDir() + vidit, err := tool.NewWithConfig(tool.Config{ + EnforceHomeDir: homeDir, + FilePickerDir: homeDir, + EarlyInit: true, + }) + require.NoError(t, err) + require.NotNil(t, vidit) + + // -------------------------------------------------------------------------------------- + // Create teatest program + tm := teatest.NewTestModel(t, vidit, teatest.WithInitialTermSize(300, 100)) + + // -------------------------------------------------------------------------------------- + // Run tool + errc := make(chan error) + wg := &sync.WaitGroup{} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + wg.Add(1) + go vidit.RunWithProgram(ctx, wg, errc, tm.GetProgram()) + go func() { + for { + select { + case errR := <-errc: + require.NoError(t, errR) + case <-ctx.Done(): + return + } + } + }() + + // -------------------------------------------------------------------------------------- + // Here should be config screen + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { + return bytes.Contains(bts, []byte("Configure ViDi endpoint URL")) + }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*3)) + t.Log("config screen showed") + + // enter endpoint + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune(srv.URL), + }) + time.Sleep(time.Millisecond * 200) + // press enter + tm.Send(tea.KeyMsg{ + Type: tea.KeyEnter, + }) + + // -------------------------------------------------------------------------------------- + // Here should be new user screen + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { + // t.Log(string(bts)) + return bytes.Contains(bts, []byte("No locally stored users have found")) + }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*3)) + t.Log("new user screen showed") + + // choose register + tm.Send(tea.KeyMsg{ + Type: tea.KeyDown, + }) + time.Sleep(time.Millisecond * 200) + tm.Send(tea.KeyMsg{ + Type: tea.KeyEnter, + }) + + // -------------------------------------------------------------------------------------- + // Here should be new user screen, second stage + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { + return bytes.Contains(bts, []byte("Provide user credentials")) + }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*3)) + t.Log("new user second screen showed") + + // enter creds + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune(VIDITe2eUser), + }) + time.Sleep(time.Millisecond * 200) + tm.Send(tea.KeyMsg{ + Type: tea.KeyEnter, + }) + time.Sleep(time.Millisecond * 200) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune(VIDITe2ePassword), + }) + time.Sleep(time.Millisecond * 200) + tm.Send(tea.KeyMsg{ + Type: tea.KeyEnter, + }) + time.Sleep(time.Millisecond * 200) + // confirm + tm.Send(tea.KeyMsg{ + Type: tea.KeyLeft, + }) + time.Sleep(time.Millisecond * 200) + tm.Send(tea.KeyMsg{ + Type: tea.KeyEnter, + }) + + // -------------------------------------------------------------------------------------- + // Here should be main screen + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { + return bytes.Contains(bts, []byte("Welcome to Vidi terminal tool")) + }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*3)) + t.Log("main menu screen showed") + + // -------------------------------------------------------------------------------------- + // Copy mp4 file, so it would be easier to find it in file picker + fileName := fmt.Sprintf("testvideo%s.mp4", random.String(5, "asdzxcqwe")) + require.NoError(t, copyFile(homeDir+"/"+fileName, "../testfiles/test_seq_h264_high_uhd.mp4"), + "copy test video file to home dir") + + // -------------------------------------------------------------------------------------- + // Upload file + + // select upload menu + tm.Send(tea.KeyMsg{ + Type: tea.KeyDown, + }) + time.Sleep(time.Millisecond * 200) + tm.Send(tea.KeyMsg{ + Type: tea.KeyEnter, + }) + time.Sleep(time.Millisecond * 200) + + // -------------------------------------------------------------------------------------- + // Here should be file picker + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { + return bytes.Contains(bts, []byte(fileName)) + }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*3)) + t.Log("file picker showed") + + // select file + tm.Send(tea.KeyMsg{ + Type: tea.KeyDown, + }) + time.Sleep(time.Millisecond * 200) + tm.Send(tea.KeyMsg{ + Type: tea.KeyDown, + }) + time.Sleep(time.Millisecond * 200) + tm.Send(tea.KeyMsg{ + Type: tea.KeyEnter, + }) + time.Sleep(time.Millisecond * 200) + + // -------------------------------------------------------------------------------------- + // Here should be file picker second screen + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { + return bytes.Contains(bts, []byte("Enter Name of the video")) + }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*3)) + + videoName := fmt.Sprintf("test video %s", random.String(5, "asdzxcqwe123")) + // enter name + tm.Send(tea.KeyMsg{ + Type: tea.KeyDown, + }) + time.Sleep(time.Millisecond * 200) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune(videoName), + }) + time.Sleep(time.Millisecond * 200) + tm.Send(tea.KeyMsg{ + Type: tea.KeyEnter, + }) + time.Sleep(time.Millisecond * 200) + // confirm + tm.Send(tea.KeyMsg{ + Type: tea.KeyLeft, + }) + time.Sleep(time.Millisecond * 200) + tm.Send(tea.KeyMsg{ + Type: tea.KeyEnter, + }) + time.Sleep(time.Millisecond * 200) + + // -------------------------------------------------------------------------------------- + // Here should eventually be upload success message + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { + return bytes.Contains(bts, []byte("Upload completed successfully! Press any key to continue...")) + }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*3)) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyEnter, + }) + time.Sleep(time.Millisecond * 200) + + // -------------------------------------------------------------------------------------- + // Here should be main screen + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { + return bytes.Contains(bts, []byte("Welcome to Vidi terminal tool")) + }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*3)) + t.Log("main menu screen showed") + + time.Sleep(time.Second * 15) + tm.Send(tea.KeyMsg{ + Type: tea.KeyEnter, + }) + time.Sleep(time.Millisecond * 200) + + // -------------------------------------------------------------------------------------- + // Here should be videos screen + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { + return bytes.Contains(bts, []byte(videoName)) && bytes.Contains(bts, []byte("ready")) + }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*1)) + t.Log("videos screen showed and uploaded video is ready") + + tm.Send(tea.KeyMsg{ + Type: tea.KeyBackspace, + }) + time.Sleep(time.Millisecond * 200) + + // -------------------------------------------------------------------------------------- + // Here should be main screen + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { + return bytes.Contains(bts, []byte("Welcome to Vidi terminal tool")) + }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*3)) + t.Log("main menu screen showed") + + // goto quotas + tm.Send(tea.KeyMsg{ + Type: tea.KeyDown, + }) + time.Sleep(time.Millisecond * 200) + tm.Send(tea.KeyMsg{ + Type: tea.KeyDown, + }) + time.Sleep(time.Millisecond * 200) + tm.Send(tea.KeyMsg{ + Type: tea.KeyEnter, + }) + time.Sleep(time.Millisecond * 200) + + // -------------------------------------------------------------------------------------- + // Here should be quotas screen + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { + return bytes.Contains(bts, []byte("size_quota")) && + bytes.Contains(bts, []byte("size_usage")) && + bytes.Contains(bts, []byte("size_remain")) && + bytes.Contains(bts, []byte("videos_quota")) && + bytes.Contains(bts, []byte("videos_usage")) && + bytes.Contains(bts, []byte("videos_remain")) + }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*3)) + t.Log("quotas screen showed") + + // go back + tm.Send(tea.KeyMsg{ + Type: tea.KeyBackspace, + }) + time.Sleep(time.Millisecond * 200) + + // -------------------------------------------------------------------------------------- + // Here should be main screen + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { + return bytes.Contains(bts, []byte("Welcome to Vidi terminal tool")) + }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*3)) + t.Log("main menu screen showed") + + // goto videos + time.Sleep(time.Second * 15) + tm.Send(tea.KeyMsg{ + Type: tea.KeyEnter, + }) + time.Sleep(time.Millisecond * 200) + + // -------------------------------------------------------------------------------------- + // Here should be videos screen + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { + return bytes.Contains(bts, []byte(videoName)) && bytes.Contains(bts, []byte("ready")) + }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*1)) + t.Log("videos screen showed and uploaded video is ready") + + // gen watch url + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("w"), + }) + time.Sleep(time.Millisecond * 200) + + // -------------------------------------------------------------------------------------- + // Here should be videos screen + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { + return bytes.Contains(bts, []byte("/manifest.mpd")) + }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*1)) + t.Log("watch url was generated") + + // delete video + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("d"), + }) + time.Sleep(time.Millisecond * 200) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("y"), + }) + time.Sleep(time.Millisecond * 200) + + // -------------------------------------------------------------------------------------- + // Here should be main screen + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { + return bytes.Contains(bts, []byte("Welcome to Vidi terminal tool")) + }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*3)) + t.Log("video deleted and main menu screen showed") + + tm.Send(tea.KeyMsg{ + Type: tea.KeyEnter, + }) + time.Sleep(time.Millisecond * 200) + + // -------------------------------------------------------------------------------------- + // Here should be videos screen + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { + return bytes.Contains(bts, []byte("")) + }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*1)) + t.Log("videos screen showed and no videos are present") + + // -------------------------------------------------------------------------------------- + // Cleanup + cancel() + wg.Wait() + + b, err = os.ReadFile(homeDir + "/log.json") + require.NoError(t, err) + t.Log(string(b)) +} + +func copyFile(dst string, src string) error { + fSrc, err := os.Open(src) + if err != nil { + return err //nolint:wrapcheck // unnecessary + } + fDst, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err //nolint:wrapcheck // unnecessary + } + _, err = fSrc.WriteTo(fDst) + return err //nolint:wrapcheck // unnecessary +} diff --git a/internal/app/app.go b/internal/app/app.go index d74b7e1..fa20aa9 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -172,8 +172,9 @@ func (app *App) setConfigDefaults() { v.SetDefault("server.grpc.address", ":8181") v.SetDefault("server.grpc.svc_address", ":8282") v.SetDefault("server.grpc.reflection", false) + v.SetDefault("server.grpc.tls_userside_enable", false) + v.SetDefault("server.grpc.tls_serviceside_enable", false) // TLS - v.SetDefault("server.tls.enable", false) v.SetDefault("server.tls.cert", "") v.SetDefault("server.tls.key", "") // Redis diff --git a/internal/app/video/app.go b/internal/app/video/app.go index be4c288..538630f 100644 --- a/internal/app/video/app.go +++ b/internal/app/video/app.go @@ -95,9 +95,10 @@ func (a *App) configure(ctx context.Context) ([]app.Runner, []app.Closer, bool) } var ( tlsKeyPath, tlsCertPath string - tlsEnable = v.GetBool("server.tls.enable") + grpcTLSEnableUsr = v.GetBool("server.grpc.tls_userside_enable") + grpcTLSEnableSvc = v.GetBool("server.grpc.tls_serviceside_enable") ) - if tlsEnable { + if grpcTLSEnableSvc || grpcTLSEnableUsr { tlsCertPath = v.GetString("server.tls.cert") tlsKeyPath = v.GetString("server.tls.key") } @@ -112,19 +113,23 @@ func (a *App) configure(ctx context.Context) ([]app.Runner, []app.Closer, bool) // Spawn application entities // -------------------------------------- // tls config - if tlsEnable { + if grpcTLSEnableSvc || grpcTLSEnableUsr { cert, err := tls.LoadX509KeyPair(tlsCertPath, tlsKeyPath) if err != nil { logger.Error("cannot create tls config", zap.Error(err)) return nil, nil, false } - gUserSrvCfg.TLSConfig = &tls.Config{ - MinVersion: minTLSVersion, - Certificates: []tls.Certificate{cert}, + if grpcTLSEnableUsr { + gUserSrvCfg.TLSConfig = &tls.Config{ + MinVersion: minTLSVersion, + Certificates: []tls.Certificate{cert}, + } } - gServiceSrvCfg.TLSConfig = &tls.Config{ - MinVersion: minTLSVersion, - Certificates: []tls.Certificate{cert}, + if grpcTLSEnableSvc { + gServiceSrvCfg.TLSConfig = &tls.Config{ + MinVersion: minTLSVersion, + Certificates: []tls.Certificate{cert}, + } } } diff --git a/internal/tool/init.go b/internal/tool/init.go index 984b087..c322f68 100644 --- a/internal/tool/init.go +++ b/internal/tool/init.go @@ -32,15 +32,24 @@ func (t *Tool) initialize() { } // initStateDir creates state dir if it not exists. -func initStateDir() (string, error) { - dir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("cannot identify home dir: %w", err) +func initStateDir(enforceHomedir string) (string, error) { + var ( + dir string + err error + ) + if enforceHomedir == "" { + dir, err = os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot identify home dir: %w", err) + } + dir += stateDir + } else { + dir = enforceHomedir } - if err = os.MkdirAll(dir+stateDir, stateDirPerm); err != nil { + if err = os.MkdirAll(dir, stateDirPerm); err != nil { return "", fmt.Errorf("cannot create state directory: %w", err) } - return dir + stateDir, nil + return dir, nil } // initClients initializes video api and user api clients using provided ViDi endpoint. diff --git a/internal/tool/init_test.go b/internal/tool/init_test.go index bb9088d..b8e31c2 100644 --- a/internal/tool/init_test.go +++ b/internal/tool/init_test.go @@ -29,13 +29,13 @@ const testRCFGMissingCA = `{ }` func TestInitStateDir(t *testing.T) { - dir, err := initStateDir() + dir, err := initStateDir(t.TempDir()) require.NoError(t, err) require.NotEmpty(t, dir) } func TestTool_initialize(t *testing.T) { - tool, err := New() + tool, err := NewWithConfig(Config{EnforceHomeDir: t.TempDir()}) require.NoError(t, err) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -46,7 +46,6 @@ func TestTool_initialize(t *testing.T) { })) defer srv.Close() - tool.dir = t.TempDir() tool.initialize() require.True(t, tool.state.noEndpoint()) @@ -109,11 +108,11 @@ func TestTool_initClientsInvalidRemoteConfig(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - tool, err := New() + tool, err := NewWithConfig(Config{EnforceHomeDir: t.TempDir()}) require.NoError(t, err) - - tool.dir = t.TempDir() + require.NotNil(t, tool) tool.initialize() + require.NotNil(t, tool.state) epURL := tt.args.epURL if tt.args.epContent != "" { diff --git a/internal/tool/tool.go b/internal/tool/tool.go index af45134..918fdf3 100644 --- a/internal/tool/tool.go +++ b/internal/tool/tool.go @@ -3,6 +3,7 @@ package tool import ( "context" + "errors" "fmt" "os" "os/signal" @@ -17,6 +18,10 @@ import ( "go.uber.org/zap" ) +const exitCodeErrAlreadyStarted = 2 + +var ErrAlreadyStarted = errors.New("already started") + type ( // Tool is a vidit tui client side tool. Tool struct { @@ -39,6 +44,8 @@ type ( // tool's homeDir dir string + // file picker dir + filePickerDir string // flag indicating that user selected to enter credentials enterCreds bool @@ -47,6 +54,9 @@ type ( // quit screen flag quitting bool + // started flag, to enforce tool's ability to be started once. + started bool + // main menu transitions mainFlowScreen int } @@ -58,11 +68,23 @@ type ( VidiCAB64 string `json:"vidi_ca"` vidiCA []byte } + + // Config is Tool's config. It is optional and used together with NewWithConfig() + Config struct { + EnforceHomeDir string + FilePickerDir string + EarlyInit bool + } ) // New creates ViDi tui tool instance. func New() (*Tool, error) { - dir, err := initStateDir() + return NewWithConfig(Config{}) +} + +// NewWithConfig creates ViDi tui tool instance using specified config. +func NewWithConfig(cfg Config) (*Tool, error) { + dir, err := initStateDir(cfg.EnforceHomeDir) if err != nil { return nil, fmt.Errorf("cannot create state dir: %w", err) } @@ -71,12 +93,25 @@ func New() (*Tool, error) { if err != nil { return nil, fmt.Errorf("cannot configure logger: %w", err) } - return &Tool{ + t := &Tool{ logger: logger, dir: dir, httpC: resty.New(), fb: make(chan tea.Msg), - }, nil + } + if cfg.FilePickerDir != "" { + t.filePickerDir = cfg.FilePickerDir + } else { + hDir, hErr := os.UserHomeDir() + if hErr != nil { + return nil, fmt.Errorf("unable to determine user home directory: %w", hErr) + } + t.filePickerDir = hDir + } + if cfg.EarlyInit { + t.initialize() + } + return t, nil } // Run starts tool. It returns only on interrupt. @@ -85,6 +120,10 @@ func (t *Tool) Run() int { } func (t *Tool) RunWithContext(ctx context.Context) int { + if t.started { + t.logger.Error("cannot start tool more than once", zap.Error(ErrAlreadyStarted)) + return exitCodeErrAlreadyStarted + } var code int ctx, cancel := signal.NotifyContext(ctx, os.Interrupt) defer cancel() @@ -98,6 +137,15 @@ func (t *Tool) RunWithContext(ctx context.Context) int { return code } +func (t *Tool) RunWithProgram(ctx context.Context, wg *sync.WaitGroup, errc chan<- error, prog *tea.Program) { + if t.started { + errc <- ErrAlreadyStarted + return + } + t.prog = prog + t.listenForEvents(ctx, wg) +} + // run spawns tea program and world event loop. func (t *Tool) run(ctx context.Context, wg *sync.WaitGroup) error { defer wg.Done() @@ -106,8 +154,10 @@ func (t *Tool) run(ctx context.Context, wg *sync.WaitGroup) error { wg.Add(1) go t.listenForEvents(ctx, wg) if _, err := t.prog.Run(); err != nil { - t.logger.Error("runtime error", zap.Error(err), zap.Stack("stack")) - return fmt.Errorf("runtime error: %w", err) + if !errors.Is(err, tea.ErrProgramKilled) { // ErrProgramKilled happens when context is canceled + t.logger.Debug("runtime error", zap.Error(err), zap.Stack("stack")) + return fmt.Errorf("runtime error: %w", err) + } } t.logger.Debug("program exited") return nil @@ -303,7 +353,7 @@ func (t *Tool) mainFlow() { t.screen = newVideosScreen(t.state.activeUserUnsafe().Videos) case mainFlowScreenUpload: // upload screen - t.screen = newUploadScreen(t.resumingUpload) + t.screen = newUploadScreen(t.filePickerDir, t.resumingUpload) default: // mainFlowScreenMainMenu // main menu u := t.state.activeUserUnsafe() diff --git a/internal/tool/tool_test.go b/internal/tool/tool_test.go index 94e0826..f00358e 100644 --- a/internal/tool/tool_test.go +++ b/internal/tool/tool_test.go @@ -3,6 +3,7 @@ package tool import ( "bytes" + "context" "fmt" "net/http" "net/http/httptest" @@ -12,14 +13,32 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/exp/teatest" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestVidit_StartStop(t *testing.T) { + tool, err := NewWithConfig(Config{EnforceHomeDir: t.TempDir()}) + require.NoError(t, err) + require.NotNil(t, tool) + + codeCh := make(chan int) + ctx, cancel := context.WithCancel(context.Background()) + go func() { + codeCh <- tool.RunWithContext(ctx) + }() + + cancel() + assert.Equal(t, 0, <-codeCh) + b, err := os.ReadFile(tool.dir + logFile) + require.NoError(t, err) + t.Log(string(b)) +} + func TestTool_StartNoEndpointFailedConnect(t *testing.T) { - tool, err := New() + tool, err := NewWithConfig(Config{EnforceHomeDir: t.TempDir()}) require.NoError(t, err) - tool.dir = t.TempDir() tool.initialize() require.NoError(t, tool.err) @@ -61,10 +80,9 @@ const validRemoveConfig = `{ }` func TestTool_StartNoUsers(t *testing.T) { - tool, err := New() + tool, err := NewWithConfig(Config{EnforceHomeDir: t.TempDir()}) require.NoError(t, err) - tool.dir = t.TempDir() tool.initialize() require.NoError(t, tool.err) @@ -107,7 +125,7 @@ const toolStateValidActiveUser = `{ }` func TestTool_StartValidActiveUser(t *testing.T) { - tool, err := New() + tool, err := NewWithConfig(Config{EnforceHomeDir: t.TempDir()}) require.NoError(t, err) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -118,7 +136,6 @@ func TestTool_StartValidActiveUser(t *testing.T) { })) defer srv.Close() - tool.dir = t.TempDir() err = os.WriteFile(tool.dir+stateFile, []byte(fmt.Sprintf(toolStateValidActiveUser, srv.URL)), 0600) require.NoError(t, err) @@ -144,7 +161,7 @@ const toolStateValidActiveUserExpiredToken = `{ }` func TestTool_StartValidActiveUserExpiredToken(t *testing.T) { - tool, err := New() + tool, err := NewWithConfig(Config{EnforceHomeDir: t.TempDir()}) require.NoError(t, err) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -155,7 +172,6 @@ func TestTool_StartValidActiveUserExpiredToken(t *testing.T) { })) defer srv.Close() - tool.dir = t.TempDir() err = os.WriteFile(tool.dir+stateFile, []byte(fmt.Sprintf(toolStateValidActiveUserExpiredToken, srv.URL)), 0600) require.NoError(t, err) diff --git a/internal/tool/uploadscreen.go b/internal/tool/uploadscreen.go index 2d8aa0a..262056e 100644 --- a/internal/tool/uploadscreen.go +++ b/internal/tool/uploadscreen.go @@ -2,7 +2,6 @@ package tool import ( "errors" - "os" "strings" "github.com/charmbracelet/bubbles/filepicker" @@ -58,7 +57,7 @@ type ( } ) -func newUploadScreen(resuming bool) *sUpload { +func newUploadScreen(startDir string, resuming bool) *sUpload { km := keyMap{ Up: key.NewBinding( key.WithKeys("up"), @@ -106,7 +105,7 @@ func newUploadScreen(resuming bool) *sUpload { u.filePicker.AllowedTypes = allowedFileTypes u.filePicker.Height = 20 u.filePicker.ShowPermissions = false - u.filePicker.CurrentDirectory, _ = os.UserHomeDir() + u.filePicker.CurrentDirectory = startDir return u } diff --git a/internal/tool/videoapi.go b/internal/tool/videoapi.go index 1d97586..911f24d 100644 --- a/internal/tool/videoapi.go +++ b/internal/tool/videoapi.go @@ -118,8 +118,14 @@ func (t *Tool) resumeUploadFileNotify(upload *Upload) { if _, err = f.Seek(int64(n)*int64(partSize), io.SeekStart); err != nil { t.fb <- fmt.Errorf("unable to seek file part %d: %w", n, err) } - resp, rErr := t.httpC.NewRequest(). - SetBody(io.LimitReader(f, int64(part.Size))). + + // Reading body bytes fully, otherwise resty will not send Content-Length + b, errB := io.ReadAll(io.LimitReader(f, int64(part.Size))) + if errB != nil { + t.fb <- fmt.Errorf("unable to read file part %d: %w", n, errB) + } + resp, rErr := t.httpC.R(). + SetBody(b). SetHeader("Content-Type", "application/x-vidi-mediapart"). Post(uploadURL + "/" + strconv.FormatUint(uint64(part.Num), 10)) if rErr != nil { @@ -216,8 +222,13 @@ func (t *Tool) uploadFileNotify(name, filePath string) { var offset uint for _, part := range upload.Parts { - resp, rErr := t.httpC.NewRequest(). - SetBody(io.LimitReader(f, int64(part.Size))). + // Reading body bytes fully, otherwise resty will not send Content-Length + b, errB := io.ReadAll(io.LimitReader(f, int64(part.Size))) + if errB != nil { + t.fb <- fmt.Errorf("unable to read file part %d: %w", part.Num, errB) + } + resp, rErr := t.httpC.R(). + SetBody(b). SetHeader("Content-Type", "application/x-vidi-mediapart"). Post(cv.UploadUrl + "/" + strconv.FormatUint(uint64(part.Num), 10)) if rErr != nil { diff --git a/testfiles/test_seq_h264_high_uhd.mp4 b/testfiles/test_seq_h264_high_uhd.mp4 new file mode 100644 index 0000000..3efdf91 Binary files /dev/null and b/testfiles/test_seq_h264_high_uhd.mp4 differ