mirror of
https://github.com/charmbracelet/vhs.git
synced 2025-11-08 23:05:05 -06:00
197 lines
5.1 KiB
Go
197 lines
5.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/keygen"
|
|
"github.com/mattn/go-isatty"
|
|
gap "github.com/muesli/go-app-paths"
|
|
"github.com/spf13/cobra"
|
|
"golang.org/x/crypto/ssh"
|
|
"golang.org/x/crypto/ssh/knownhosts"
|
|
)
|
|
|
|
const (
|
|
ghostHost = "ghost.charm.sh"
|
|
ghostPort = 22
|
|
)
|
|
|
|
var publishCmd = &cobra.Command{
|
|
Use: "publish <gif>",
|
|
Short: "Publish your GIF to vhs.charm.sh and get a shareable URL",
|
|
Args: cobra.ExactArgs(1),
|
|
SilenceUsage: true,
|
|
SilenceErrors: true, // we print our own errors
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
file := args[0]
|
|
if strings.HasSuffix(file, ".tape") {
|
|
log.Printf("Use vhs %s --publish flag to publish tapes\n", file)
|
|
return errors.New("must pass a GIF file")
|
|
}
|
|
if !strings.HasSuffix(file, gif) {
|
|
return errors.New("must pass a GIF file")
|
|
}
|
|
|
|
url, err := Publish(cmd.Context(), file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if quietFlag || !isatty.IsTerminal(os.Stdout.Fd()) {
|
|
fmt.Println(url)
|
|
return nil
|
|
}
|
|
publishShareInstructions(url)
|
|
cmd.Print(" " + URLStyle.Render(url))
|
|
cmd.Println()
|
|
return nil
|
|
},
|
|
}
|
|
|
|
//nolint:wrapcheck
|
|
func dataPath() (string, error) {
|
|
scope := gap.NewScope(gap.User, "vhs")
|
|
dataPath, err := scope.DataPath("")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return dataPath, nil
|
|
}
|
|
|
|
// hostKeyCallback returns a callback that will be used to verify the host key.
|
|
//
|
|
// it creates a file in the given path, and uses that to verify hosts and keys.
|
|
// if the host does not exist there, it adds it so its available next time, as plain old `ssh` does.
|
|
func hostKeyCallback(path string) ssh.HostKeyCallback {
|
|
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
|
kh, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) //nolint:mnd
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open known_hosts: %w", err)
|
|
}
|
|
defer func() { _ = kh.Close() }()
|
|
|
|
callback, err := knownhosts.New(kh.Name())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check known_hosts: %w", err)
|
|
}
|
|
|
|
if err := callback(hostname, remote, key); err != nil {
|
|
var kerr *knownhosts.KeyError
|
|
if errors.As(err, &kerr) {
|
|
if len(kerr.Want) > 0 {
|
|
return fmt.Errorf("possible man-in-the-middle attack: %w", err)
|
|
}
|
|
// if want is empty, it means the host was not in the known_hosts file, so lets add it there.
|
|
_, _ = fmt.Fprintln(kh, knownhosts.Line([]string{hostname}, key))
|
|
return nil
|
|
}
|
|
return fmt.Errorf("failed to check known_hosts: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
//nolint:wrapcheck
|
|
func sshSession() (*ssh.Session, error) {
|
|
dp, err := dataPath()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
kp, err := keygen.New(filepath.Join(dp, "vhs_ed25519"), keygen.WithKeyType(keygen.Ed25519), keygen.WithWrite())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
signer, err := ssh.NewSignerFromKey(kp.PrivateKey())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
pkam := ssh.PublicKeys(signer)
|
|
sshConfig := &ssh.ClientConfig{
|
|
User: "vhs",
|
|
Auth: []ssh.AuthMethod{pkam},
|
|
HostKeyCallback: hostKeyCallback(filepath.Join(dp, "known_hosts")),
|
|
}
|
|
|
|
c, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", ghostHost, ghostPort), sshConfig)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s, err := c.NewSession()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// publishShareInstructions log shareable URL
|
|
// If log level is set to `logLevelQuiet` the log message will be forced.
|
|
func publishShareInstructions(url string) {
|
|
log.Println("\n" + GrayStyle.Render(" Share your GIF with Markdown:"))
|
|
log.Println(CommandStyle.Render(" ![Made with VHS]") + URLStyle.Render("("+url+")"))
|
|
log.Println(GrayStyle.Render("\n Or HTML (with badge):"))
|
|
log.Println(CommandStyle.Render(" <img ") + CommandStyle.Render("src=") + URLStyle.Render(`"`+url+`"`) + CommandStyle.Render(" alt=") + URLStyle.Render(`"Made with VHS"`) + CommandStyle.Render(">"))
|
|
log.Println(CommandStyle.Render(" <a ") + CommandStyle.Render("href=") + URLStyle.Render(`"https://vhs.charm.sh"`) + CommandStyle.Render(">"))
|
|
log.Println(CommandStyle.Render(" <img ") + CommandStyle.Render("src=") + URLStyle.Render(`"https://stuff.charm.sh/vhs/badge.svg"`) + CommandStyle.Render(">"))
|
|
log.Println(CommandStyle.Render(" </a>"))
|
|
log.Println(GrayStyle.Render("\n Or link to it:"))
|
|
}
|
|
|
|
// Publish publishes the given GIF file to the web.
|
|
//
|
|
//nolint:wrapcheck
|
|
func Publish(ctx context.Context, path string) (string, error) {
|
|
s, err := sshSession()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer s.Close() //nolint:errcheck
|
|
|
|
// Close connection when context is done
|
|
go func() {
|
|
<-ctx.Done()
|
|
_ = s.Close()
|
|
}()
|
|
|
|
in, err := s.StdinPipe()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
out, err := s.StdoutPipe()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close() //nolint:errcheck
|
|
|
|
if err := s.Start(""); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
_, err = io.Copy(in, f)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
_ = in.Close()
|
|
|
|
b, err := io.ReadAll(out)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return string(b), nil
|
|
}
|