cairde/cairde.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()
}