531 lines
10 KiB
Go
531 lines
10 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 (
|
|
initialising = iota
|
|
initialised
|
|
|
|
help = `cairde [options]
|
|
|
|
A text-based user interface for metadata resistant online chat.
|
|
|
|
Options:
|
|
-h output help
|
|
`
|
|
cmdHelp = `/help
|
|
/profile create <name> <password> <password>
|
|
/profile unlock <password>`
|
|
)
|
|
|
|
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 (
|
|
backgroundStyle = lipgloss.NewStyle().
|
|
Align(lipgloss.Center)
|
|
|
|
mainStyle = lipgloss.NewStyle().
|
|
BorderStyle(lipgloss.NormalBorder())
|
|
|
|
titleStyle = lipgloss.NewStyle().
|
|
Height(1).
|
|
Align(lipgloss.Center).
|
|
Bold(true)
|
|
|
|
viewportStyle = lipgloss.NewStyle().
|
|
BorderStyle(lipgloss.NormalBorder()).
|
|
MarginLeft(1)
|
|
|
|
profileNameButtonStyle = lipgloss.NewStyle().
|
|
Height(1).
|
|
BorderStyle(lipgloss.NormalBorder()).
|
|
Padding(0, 1).
|
|
Bold(true)
|
|
|
|
inputStyle = lipgloss.NewStyle().
|
|
Height(1).
|
|
PaddingLeft(1)
|
|
|
|
chatStyle = lipgloss.NewStyle().
|
|
PaddingLeft(1)
|
|
)
|
|
|
|
type model struct {
|
|
username string
|
|
userDir string
|
|
|
|
state int
|
|
|
|
app app.Application
|
|
acn connectivity.ACN
|
|
|
|
width int
|
|
height int
|
|
|
|
programName string
|
|
programVersion string
|
|
title string
|
|
|
|
viewport viewport.Model
|
|
|
|
profileNames []string
|
|
profileState int
|
|
|
|
input textinput.Model
|
|
|
|
statusMsgs *[]string
|
|
}
|
|
|
|
func newModel(username, homeDir string) model {
|
|
input := textinput.New()
|
|
input.Focus()
|
|
|
|
return model{
|
|
username: username,
|
|
userDir: path.Join(homeDir, "/.cairde/"),
|
|
|
|
state: initialising,
|
|
|
|
programName: "cairde",
|
|
programVersion: "v0.1.0",
|
|
title: fmt.Sprintf("cairde@v0.1.0"),
|
|
|
|
profileNames: []string{"status"},
|
|
profileState: 0,
|
|
|
|
input: input,
|
|
|
|
statusMsgs: new([]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) statusInfo(msg string) {
|
|
*m.statusMsgs = append(*m.statusMsgs, msg)
|
|
}
|
|
|
|
func (m model) initApp() tea.Msg {
|
|
m.statusInfo("attempting to bootstrap Tor ACN...")
|
|
|
|
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)}
|
|
}
|
|
|
|
m.statusInfo("ensuring tor/profile directories are created...")
|
|
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.statusInfo("initialising 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.statusInfo("waiting for ACN to bootstrap...")
|
|
if err := acn.WaitTillBootstrapped(); err != nil {
|
|
return errMsg{Err: fmt.Errorf("unable to initialise tor: %s", err)}
|
|
}
|
|
|
|
m.statusInfo("initialising global settings...")
|
|
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)
|
|
|
|
m.statusInfo("bootstrapping application...")
|
|
app := app.NewApp(acn, m.userDir, settingsFile)
|
|
|
|
m.statusInfo("installing engine hooks...")
|
|
engineHooks := connections.DefaultEngineHooks{}
|
|
app.InstallEngineHooks(engineHooks)
|
|
|
|
m.statusInfo("loading unencrypted profiles...")
|
|
app.LoadProfiles("")
|
|
|
|
m.statusInfo("Tor ACN is online")
|
|
|
|
return appInitialisedMsg{
|
|
app: app,
|
|
acn: acn,
|
|
}
|
|
}
|
|
|
|
func (m model) Init() tea.Cmd {
|
|
return tea.Batch(
|
|
textinput.Blink,
|
|
m.initApp,
|
|
)
|
|
}
|
|
|
|
func (m model) isInitialising() bool {
|
|
if m.state == initialising {
|
|
m.statusInfo("still initialising, please hold...")
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m model) shutdown() bool {
|
|
if m.isInitialising() {
|
|
return false
|
|
}
|
|
m.app.Shutdown()
|
|
m.acn.Close()
|
|
return true
|
|
}
|
|
|
|
type cmdMsg struct {
|
|
output string
|
|
}
|
|
|
|
type shutdownMsg struct{}
|
|
|
|
type createProfileMsg struct {
|
|
name string
|
|
password string
|
|
}
|
|
|
|
type profileUnlockMsg struct {
|
|
password string
|
|
}
|
|
|
|
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 handleCommand(cmd 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}
|
|
}
|
|
|
|
// TODO: check passwords match
|
|
|
|
return createProfileMsg{
|
|
name: cmds[2],
|
|
password: cmds[3],
|
|
}
|
|
|
|
case isProfileUnlockCmd(cmds):
|
|
if len(cmds) != 3 {
|
|
return cmdMsg{output: cmdHelp}
|
|
}
|
|
|
|
return profileUnlockMsg{
|
|
password: cmds[2],
|
|
}
|
|
|
|
default:
|
|
return cmdMsg{output: "unknown command"}
|
|
}
|
|
}
|
|
|
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
var (
|
|
cmd tea.Cmd
|
|
cmds []tea.Cmd
|
|
)
|
|
|
|
m.input, cmd = m.input.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
|
|
m.viewport, cmd = m.viewport.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
|
|
// TODO: clean up
|
|
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 {
|
|
if string(val[len(val)-1]) != " " {
|
|
newCmd := []rune(val)
|
|
newCmd[len(val)-1] = '*'
|
|
m.input.SetValue(string(newCmd))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width - 2
|
|
m.height = msg.Height - 2
|
|
m.viewport = viewport.New(
|
|
m.width-4,
|
|
m.height-8,
|
|
)
|
|
|
|
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:])
|
|
})
|
|
}
|
|
}
|
|
|
|
case cmdMsg:
|
|
m.input.Reset()
|
|
m.statusInfo(msg.output)
|
|
|
|
case shutdownMsg:
|
|
m.input.Reset()
|
|
if m.shutdown() {
|
|
return m, tea.Quit
|
|
}
|
|
|
|
case createProfileMsg:
|
|
m.input.Reset()
|
|
m.app.CreateProfile(msg.name, msg.password, false)
|
|
m.statusInfo(fmt.Sprintf("created new profile: %s", msg.name))
|
|
m.profileNames = append(m.profileNames, msg.name)
|
|
|
|
case profileUnlockMsg:
|
|
m.input.Reset()
|
|
if m.isInitialising() {
|
|
break
|
|
}
|
|
m.app.LoadProfiles(msg.password)
|
|
m.statusInfo("Unlocking profiles...")
|
|
profileNames := []string{"status"}
|
|
for _, onion := range m.app.ListProfiles() {
|
|
peer := m.app.GetPeer(onion)
|
|
name, _ := peer.GetScopedZonedAttribute(attr.PublicScope, attr.ProfileZone, constants.Name)
|
|
profileNames = append(profileNames, name)
|
|
}
|
|
m.profileNames = profileNames
|
|
|
|
case appInitialisedMsg:
|
|
m.app = msg.app
|
|
m.acn = msg.acn
|
|
m.state = initialised
|
|
|
|
case errMsg:
|
|
m.statusInfo(msg.Error())
|
|
}
|
|
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
func (m model) View() string {
|
|
body := strings.Builder{}
|
|
|
|
// TODO: adjust when more panes are available
|
|
m.viewport.SetContent(strings.Join(*m.statusMsgs, "\n"))
|
|
|
|
var profileNames []string
|
|
for idx, profileName := range m.profileNames {
|
|
rendered := profileNameButtonStyle.Render(profileName)
|
|
|
|
if idx > 0 || len(m.profileNames) == 1 {
|
|
rendered = profileNameButtonStyle.
|
|
MarginLeft(1).
|
|
Render(profileName)
|
|
}
|
|
|
|
profileNames = append(profileNames, rendered)
|
|
}
|
|
|
|
body.WriteString(
|
|
mainStyle.
|
|
Width(m.width).
|
|
Height(m.height).
|
|
Render(
|
|
lipgloss.JoinVertical(
|
|
lipgloss.Top,
|
|
|
|
lipgloss.PlaceHorizontal(
|
|
m.width,
|
|
lipgloss.Center,
|
|
titleStyle.
|
|
Width(m.width).
|
|
Render(
|
|
backgroundStyle.
|
|
Width(lipgloss.Width(m.title)+2).
|
|
Render(m.title),
|
|
),
|
|
),
|
|
|
|
viewportStyle.
|
|
Width(m.width-4).
|
|
Render(
|
|
chatStyle.Render(
|
|
m.viewport.View(),
|
|
),
|
|
),
|
|
|
|
lipgloss.JoinHorizontal(
|
|
lipgloss.Left,
|
|
profileNames...,
|
|
),
|
|
|
|
inputStyle.Render(
|
|
m.input.View(),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
|
|
return body.String()
|
|
}
|