615 lines
12 KiB
Go
615 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
mrand "math/rand"
|
|
"os"
|
|
"os/exec"
|
|
"os/user"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"cwtch.im/cwtch/app"
|
|
"cwtch.im/cwtch/model/attr"
|
|
"cwtch.im/cwtch/model/constants"
|
|
"cwtch.im/cwtch/protocol/connections"
|
|
"cwtch.im/cwtch/settings"
|
|
"git.openprivacy.ca/openprivacy/connectivity"
|
|
"git.openprivacy.ca/openprivacy/connectivity/tor"
|
|
openPrivacyLog "git.openprivacy.ca/openprivacy/log"
|
|
_ "github.com/mutecomm/go-sqlcipher/v4"
|
|
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
const (
|
|
offline = iota
|
|
connecting
|
|
connected
|
|
|
|
help = `cairde [options]
|
|
|
|
A text-based user interface for metadata resistant online chat.
|
|
|
|
Options:
|
|
-h output help
|
|
`
|
|
cmdHelp = `/clear
|
|
/connect
|
|
/help
|
|
/profile create <name> <password> <password>
|
|
/profile info
|
|
/profile unlock <password>
|
|
/quit`
|
|
)
|
|
|
|
func main() {
|
|
var helpFlag bool
|
|
|
|
flag.BoolVar(&helpFlag, "h", false, "output help")
|
|
flag.Parse()
|
|
|
|
if helpFlag {
|
|
fmt.Print(help)
|
|
os.Exit(0)
|
|
}
|
|
|
|
// NOTE(d1): pending https://git.coopcloud.tech/decentral1se/cairde/issues/1
|
|
_, err := exec.LookPath("tor")
|
|
if err != nil {
|
|
log.Fatal("could not find 'tor' command, is it installed?")
|
|
}
|
|
|
|
f, err := tea.LogToFile("cairde.log", "debug")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
filelogger, err := openPrivacyLog.NewFile(openPrivacyLog.LevelInfo, "cwtch.log")
|
|
if err == nil {
|
|
openPrivacyLog.SetStd(filelogger)
|
|
}
|
|
|
|
user, err := user.Current()
|
|
if err != nil {
|
|
log.Fatalf("unable to determine current user: %s", err)
|
|
}
|
|
|
|
p := tea.NewProgram(
|
|
newModel(user.Username, user.HomeDir),
|
|
tea.WithAltScreen(),
|
|
tea.WithMouseCellMotion(),
|
|
)
|
|
|
|
if err := p.Start(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
var (
|
|
mainStyle = lipgloss.NewStyle().
|
|
BorderStyle(lipgloss.NormalBorder())
|
|
|
|
viewportStyle = lipgloss.NewStyle()
|
|
|
|
profileNameButtonStyle = lipgloss.NewStyle()
|
|
|
|
channelBarStyle = lipgloss.NewStyle()
|
|
|
|
inputStyle = lipgloss.NewStyle()
|
|
)
|
|
|
|
type profile struct {
|
|
name string
|
|
onion string
|
|
}
|
|
|
|
type model struct {
|
|
username string
|
|
userDir string
|
|
|
|
connState int
|
|
|
|
app app.Application
|
|
acn connectivity.ACN
|
|
|
|
width int
|
|
height int
|
|
|
|
viewport viewport.Model
|
|
|
|
profiles []profile
|
|
profileState int
|
|
|
|
input textinput.Model
|
|
hiddenInput string
|
|
|
|
statusBuffer chan string
|
|
}
|
|
|
|
func newModel(username, homeDir string) model {
|
|
input := textinput.New()
|
|
input.Prompt = ""
|
|
input.SetCursorMode(textinput.CursorStatic)
|
|
input.Focus()
|
|
|
|
return model{
|
|
username: username,
|
|
userDir: path.Join(homeDir, "/.cairde/"),
|
|
connState: offline,
|
|
input: input,
|
|
statusBuffer: make(chan string),
|
|
}
|
|
}
|
|
|
|
type errMsg struct{ Err error }
|
|
|
|
func (e errMsg) Error() string {
|
|
return e.Err.Error()
|
|
}
|
|
|
|
type appInitialisedMsg struct {
|
|
app app.Application
|
|
acn connectivity.ACN
|
|
}
|
|
|
|
func (m model) initApp() tea.Cmd {
|
|
return func() tea.Msg {
|
|
mrand.Seed(int64(time.Now().Nanosecond()))
|
|
port := mrand.Intn(1000) + 9600
|
|
controlPort := port + 1
|
|
|
|
key := make([]byte, 64)
|
|
_, err := rand.Read(key)
|
|
if err != nil {
|
|
return errMsg{Err: fmt.Errorf("unable to generate control port password: %s", err)}
|
|
}
|
|
|
|
if err := os.MkdirAll(path.Join(m.userDir, "/.tor", "tor"), 0700); err != nil {
|
|
return errMsg{Err: fmt.Errorf("unable to create tor directory: %s", err)}
|
|
}
|
|
|
|
if err := os.MkdirAll(path.Join(m.userDir, "profiles"), 0700); err != nil {
|
|
return errMsg{Err: fmt.Errorf("unable to create profiles directory: %s", err)}
|
|
}
|
|
|
|
if err := tor.NewTorrc().
|
|
WithSocksPort(port).
|
|
WithOnionTrafficOnly().
|
|
WithControlPort(controlPort).
|
|
WithHashedPassword(base64.StdEncoding.EncodeToString(key)).
|
|
Build(filepath.Join(m.userDir, ".tor", "tor", "torrc")); err != nil {
|
|
return errMsg{Err: fmt.Errorf("unable to initialise torrc builder: %s", err)}
|
|
}
|
|
|
|
m.statusBuffer <- "initialising Tor ACN..."
|
|
acn, err := tor.NewTorACNWithAuth(
|
|
path.Join(m.userDir, "/.tor"),
|
|
"",
|
|
path.Join(m.userDir, "/.tor", "data"),
|
|
controlPort,
|
|
tor.HashedPasswordAuthenticator{
|
|
Password: base64.StdEncoding.EncodeToString(key),
|
|
},
|
|
)
|
|
if err != nil {
|
|
return errMsg{Err: fmt.Errorf("unable to bootstrap tor: %s", err)}
|
|
}
|
|
|
|
m.statusBuffer <- "waiting for Tor ACN to bootstrap..."
|
|
if err := acn.WaitTillBootstrapped(); err != nil {
|
|
return errMsg{Err: fmt.Errorf("unable to initialise tor: %s", err)}
|
|
}
|
|
|
|
settingsFile, err := settings.InitGlobalSettingsFile(m.userDir, "")
|
|
if err != nil {
|
|
return errMsg{Err: fmt.Errorf("unable to initialise settings: %s", err)}
|
|
}
|
|
gSettings := settingsFile.ReadGlobalSettings()
|
|
gSettings.ExperimentsEnabled = false
|
|
gSettings.DownloadPath = "./"
|
|
settingsFile.WriteGlobalSettings(gSettings)
|
|
|
|
app := app.NewApp(acn, m.userDir, settingsFile)
|
|
|
|
engineHooks := connections.DefaultEngineHooks{}
|
|
app.InstallEngineHooks(engineHooks)
|
|
|
|
m.statusBuffer <- "the Tor ACN is up"
|
|
|
|
return appInitialisedMsg{
|
|
app: app,
|
|
acn: acn,
|
|
}
|
|
}
|
|
}
|
|
|
|
type statusMsg struct{ msg string }
|
|
|
|
func (m model) handleStatusMessage() tea.Cmd {
|
|
return func() tea.Msg {
|
|
return statusMsg{msg: <-m.statusBuffer}
|
|
}
|
|
}
|
|
|
|
func (m model) Init() tea.Cmd {
|
|
return tea.Batch(
|
|
textinput.Blink,
|
|
m.handleStatusMessage(),
|
|
)
|
|
}
|
|
|
|
func (m model) isConnecting() bool {
|
|
if m.connState == connecting {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func (m model) isOffline() bool {
|
|
if m.connState == offline {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func (m model) shutdown() bool {
|
|
if m.isConnecting() {
|
|
return false
|
|
}
|
|
|
|
if m.isOffline() {
|
|
return true
|
|
}
|
|
|
|
m.app.Shutdown()
|
|
m.acn.Close()
|
|
close(m.statusBuffer)
|
|
|
|
return true
|
|
}
|
|
|
|
type cmdMsg struct {
|
|
output string
|
|
}
|
|
|
|
type shutdownMsg struct{}
|
|
|
|
type createProfileMsg struct {
|
|
name string
|
|
password string
|
|
}
|
|
|
|
type profileUnlockMsg struct {
|
|
password string
|
|
}
|
|
|
|
type connectMsg struct{}
|
|
|
|
type clearMsg struct{}
|
|
|
|
type profileInfoMsg struct{}
|
|
|
|
func isHelpCmd(cmds []string) bool {
|
|
if cmds[0] == "help" {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isQuitCmd(cmds []string) bool {
|
|
if cmds[0] == "quit" {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isProfileCreateCmd(cmds []string) bool {
|
|
if cmds[0] == "profile" && cmds[1] == "create" {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isProfileUnlockCmd(cmds []string) bool {
|
|
if cmds[0] == "profile" && cmds[1] == "unlock" {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isProfileInfoCmd(cmds []string) bool {
|
|
if cmds[0] == "profile" && cmds[1] == "info" {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isConnectCmd(cmds []string) bool {
|
|
if cmds[0] == "connect" {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isClearCmd(cmds []string) bool {
|
|
if cmds[0] == "clear" {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func handleCommand(cmd, hiddenInput string) tea.Msg {
|
|
cmds := strings.Split(cmd, " ")
|
|
|
|
switch {
|
|
case isHelpCmd(cmds):
|
|
return cmdMsg{output: cmdHelp}
|
|
|
|
case isQuitCmd(cmds):
|
|
return shutdownMsg{}
|
|
|
|
case isProfileCreateCmd(cmds):
|
|
if len(cmds) != 5 {
|
|
return cmdMsg{output: cmdHelp}
|
|
}
|
|
|
|
passwords := strings.Split(strings.TrimSpace(hiddenInput), " ")
|
|
if passwords[0] != passwords[1] {
|
|
return cmdMsg{output: "profile create: passwords do not match?"}
|
|
}
|
|
|
|
return createProfileMsg{
|
|
name: cmds[2],
|
|
password: passwords[1],
|
|
}
|
|
|
|
case isProfileUnlockCmd(cmds):
|
|
if len(cmds) != 3 {
|
|
return cmdMsg{output: cmdHelp}
|
|
}
|
|
|
|
return profileUnlockMsg{
|
|
password: strings.TrimSpace(hiddenInput),
|
|
}
|
|
|
|
case isProfileInfoCmd(cmds):
|
|
return profileInfoMsg{}
|
|
|
|
case isConnectCmd(cmds):
|
|
return connectMsg{}
|
|
|
|
case isClearCmd(cmds):
|
|
return clearMsg{}
|
|
|
|
default:
|
|
return cmdMsg{output: "unknown command"}
|
|
}
|
|
}
|
|
|
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
var (
|
|
cmd tea.Cmd
|
|
cmds []tea.Cmd
|
|
)
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width - 2
|
|
m.height = msg.Height - 2
|
|
m.viewport = viewport.New(
|
|
m.width,
|
|
m.height-3,
|
|
)
|
|
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "ctrl+c":
|
|
if m.shutdown() {
|
|
return m, tea.Quit
|
|
}
|
|
|
|
case "enter":
|
|
val := m.input.Value()
|
|
if len(val) == 0 {
|
|
break
|
|
}
|
|
|
|
if string(val[0]) == "/" {
|
|
cmds = append(cmds, func() tea.Msg {
|
|
return handleCommand(val[1:], m.hiddenInput)
|
|
})
|
|
}
|
|
default:
|
|
val := m.input.Value()
|
|
if len(val) != 0 {
|
|
if string(val[0]) == "/" {
|
|
cmds := strings.Split(val[1:], " ")
|
|
if cmds[0] == "profile" && len(cmds) >= 2 {
|
|
if cmds[1] == "create" && len(cmds) > 3 || cmds[1] == "unlock" && len(cmds) > 2 {
|
|
lastChar := string(val[len(val)-1])
|
|
m.hiddenInput += lastChar
|
|
if lastChar != " " {
|
|
newCmd := []rune(val)
|
|
newCmd[len(val)-1] = '*'
|
|
m.input.SetValue(string(newCmd))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
case cmdMsg:
|
|
m.input.Reset()
|
|
m.statusBuffer <- msg.output
|
|
|
|
case shutdownMsg:
|
|
m.input.Reset()
|
|
if m.shutdown() {
|
|
return m, tea.Quit
|
|
}
|
|
|
|
case createProfileMsg:
|
|
m.hiddenInput = ""
|
|
m.input.Reset()
|
|
|
|
m.app.CreateProfile(msg.name, msg.password, false)
|
|
m.statusBuffer <- fmt.Sprintf("created new profile: %s", msg.name)
|
|
|
|
profiles := m.app.ListProfiles() // TODO
|
|
m.profiles = append(m.profiles, profile{
|
|
name: msg.name,
|
|
onion: profiles[len(profiles)-1],
|
|
})
|
|
|
|
case profileUnlockMsg:
|
|
m.hiddenInput = ""
|
|
m.input.Reset()
|
|
|
|
switch m.connState {
|
|
case offline:
|
|
m.statusBuffer <- "offline, run /connect first?"
|
|
break
|
|
case connecting:
|
|
m.statusBuffer <- "still connecting, hold on a sec..."
|
|
break
|
|
}
|
|
|
|
m.app.LoadProfiles(msg.password)
|
|
|
|
var unlocked []profile
|
|
for _, onion := range m.app.ListProfiles() {
|
|
peer := m.app.GetPeer(onion)
|
|
name, _ := peer.GetScopedZonedAttribute(attr.PublicScope, attr.ProfileZone, constants.Name)
|
|
unlocked = append(unlocked, profile{name: name, onion: onion})
|
|
}
|
|
|
|
if len(unlocked) > 0 {
|
|
var names []string
|
|
for _, p := range unlocked {
|
|
names = append(names, p.name)
|
|
}
|
|
m.statusBuffer <- fmt.Sprintf("unlocked profile(s): %s", strings.Join(names, " "))
|
|
}
|
|
|
|
m.profiles = unlocked
|
|
|
|
case profileInfoMsg:
|
|
m.input.Reset()
|
|
if m.connState != connected {
|
|
m.statusBuffer <- "offline, run /connect first?"
|
|
break
|
|
}
|
|
if len(m.profiles) == 0 {
|
|
m.statusBuffer <- "no profiles loaded"
|
|
break
|
|
}
|
|
profile := m.profiles[m.profileState]
|
|
m.statusBuffer <- fmt.Sprintf("profile onion: %s", profile.onion)
|
|
|
|
case statusMsg:
|
|
existing := m.viewport.View()
|
|
m.viewport.SetContent(existing + "\n" + msg.msg)
|
|
m.viewport.GotoBottom()
|
|
cmds = append(cmds, m.handleStatusMessage())
|
|
|
|
case clearMsg:
|
|
m.input.Reset()
|
|
m.viewport.SetContent("")
|
|
m.viewport.GotoBottom()
|
|
|
|
case connectMsg:
|
|
m.input.Reset()
|
|
cmds = append(cmds, m.initApp())
|
|
|
|
case appInitialisedMsg:
|
|
m.app = msg.app
|
|
m.acn = msg.acn
|
|
m.connState = connected
|
|
m.statusBuffer <- "create or unlock a profile to get started"
|
|
m.statusBuffer <- "run /help to see the command listing"
|
|
|
|
case errMsg:
|
|
m.statusBuffer <- msg.Error()
|
|
}
|
|
|
|
m.input, cmd = m.input.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
|
|
m.viewport, cmd = m.viewport.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
func (m model) View() string {
|
|
body := strings.Builder{}
|
|
|
|
channelBar := channelBarStyle.
|
|
Width(m.width).
|
|
AlignVertical(lipgloss.Center).
|
|
Render(lipgloss.NewStyle().Background(lipgloss.Color("14")).Foreground(lipgloss.Color("#fffff")).Render("N/A"))
|
|
|
|
statusButton := profileNameButtonStyle.
|
|
Width(m.width).
|
|
AlignVertical(lipgloss.Center).
|
|
Render(lipgloss.NewStyle().Background(lipgloss.Color("26")).Foreground(lipgloss.Color("#fffff")).Render("status"))
|
|
|
|
var profileNames []string
|
|
for _, profile := range m.profiles {
|
|
rendered := profileNameButtonStyle.Render(profile.name)
|
|
profileNames = append(profileNames, rendered)
|
|
}
|
|
|
|
buttonBar := []string{statusButton}
|
|
buttonBar = append(buttonBar, profileNames...)
|
|
|
|
body.WriteString(
|
|
mainStyle.
|
|
Width(m.width).
|
|
Height(m.height).
|
|
Render(
|
|
lipgloss.JoinVertical(
|
|
lipgloss.Top,
|
|
|
|
viewportStyle.
|
|
Width(m.width).
|
|
Render(m.viewport.View()),
|
|
|
|
lipgloss.JoinHorizontal(
|
|
lipgloss.Left,
|
|
channelBar,
|
|
),
|
|
|
|
lipgloss.JoinHorizontal(
|
|
lipgloss.Left,
|
|
buttonBar...,
|
|
),
|
|
|
|
lipgloss.PlaceVertical(
|
|
1,
|
|
lipgloss.Bottom,
|
|
inputStyle.
|
|
Width(m.width).
|
|
Render(m.input.View()),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
|
|
return body.String()
|
|
}
|