feat: add Enbas

Add Enbas code. So far Enbas can:

- Allow the user to register the application and log into GTS on their
  behalf. The scope is limited to read for now.
- Show instance details.
- Show local and remote accounts.
This commit is contained in:
Dan Anglin 2024-02-23 09:44:57 +00:00
parent 2544fe4fcf
commit 2c5123253a
Signed by: dananglin
GPG key ID: 0C1D44CFBEE68638
21 changed files with 1342 additions and 11 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/environment/
/enbas

View file

@ -13,10 +13,19 @@ output:
sort-results: true
linters-settings:
depguard:
rules:
main:
files:
- $all
allow:
- $gostd
- codeflow.dananglin.me.uk/apollo/enbas
lll:
line-length: 140
linters:
enable-all: true
# disable:
disable:
#- json
fast: false

159
cmd/enbas/login.go Normal file
View file

@ -0,0 +1,159 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"os/exec"
"runtime"
"strings"
"codeflow.dananglin.me.uk/apollo/enbas/internal"
"codeflow.dananglin.me.uk/apollo/enbas/internal/client"
"codeflow.dananglin.me.uk/apollo/enbas/internal/config"
"golang.org/x/oauth2"
)
type loginCommand struct {
*flag.FlagSet
instance string
}
var (
errEmptyAccessToken = errors.New("received an empty access token")
errInstanceNotSet = errors.New("the instance flag is not set")
)
var consentMessageFormat = `
You'll need to sign into your GoToSocial's consent page in order to generate the out-of-band token to continue with
the application's login process. Your browser may have opened the link to the consent page already. If not, please
copy and paste the link below to your browser:
%s
Once you have the code please copy and paste it below.
`
func newLoginCommand(name, summary string) *loginCommand {
command := loginCommand{
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
instance: "",
}
command.StringVar(&command.instance, "instance", "", "specify the instance that you want to login to.")
command.Usage = commandUsageFunc(name, summary, command.FlagSet)
return &command
}
func (c *loginCommand) Execute() error {
var err error
if c.instance == "" {
return errInstanceNotSet
}
instance := c.instance
if !strings.HasPrefix(instance, "https") || !strings.HasPrefix(instance, "http") {
instance = "https://" + instance
}
for strings.HasSuffix(instance, "/") {
instance = instance[:len(instance)-1]
}
authentication := config.Authentication{
Instance: instance,
}
gtsClient := client.NewClient(authentication)
if err := gtsClient.Register(); err != nil {
return fmt.Errorf("unable to register the application; %w", err)
}
oauth2Conf := oauth2.Config{
ClientID: gtsClient.Authentication.ClientID,
ClientSecret: gtsClient.Authentication.ClientSecret,
Scopes: []string{"read"},
RedirectURL: internal.RedirectUri,
Endpoint: oauth2.Endpoint{
AuthURL: gtsClient.Authentication.Instance + "/oauth/authorize",
TokenURL: gtsClient.Authentication.Instance + "/oauth/token",
},
}
consentPageURL := authCodeURL(oauth2Conf)
openLink(consentPageURL)
fmt.Printf(consentMessageFormat, consentPageURL)
var code string
fmt.Print("Out-of-band token: ")
if _, err := fmt.Scanln(&code); err != nil {
return fmt.Errorf("failed to read access code; %w", err)
}
gtsClient.Authentication, err = addAccessToken(gtsClient.Authentication, oauth2Conf, code)
if err != nil {
return fmt.Errorf("unable to get the access token; %w", err)
}
account, err := gtsClient.VerifyCredentials()
if err != nil {
return fmt.Errorf("unable to verify the credentials; %w", err)
}
loginName, err := config.SaveAuthentication(account.Username, gtsClient.Authentication)
if err != nil {
return fmt.Errorf("unable to save the authentication details; %w", err)
}
fmt.Printf("Successfully logged into %s\n", loginName)
return nil
}
func authCodeURL(oauth2Conf oauth2.Config) string {
url := oauth2Conf.AuthCodeURL(
"state",
oauth2.AccessTypeOffline,
) + "&client_name=" + internal.ApplicationName
return url
}
func addAccessToken(authentication config.Authentication, oauth2Conf oauth2.Config, code string) (config.Authentication, error) {
token, err := oauth2Conf.Exchange(context.Background(), code)
if err != nil {
return config.Authentication{}, fmt.Errorf("unable to exchange the code for an access token; %w", err)
}
if token == nil || token.AccessToken == "" {
return config.Authentication{}, errEmptyAccessToken
}
authentication.AccessToken = token.AccessToken
return authentication, nil
}
func openLink(url string) {
var open string
if runtime.GOOS == "linux" {
open = "xdg-open"
} else {
return
}
command := exec.Command(open, url)
_ = command.Start()
}

75
cmd/enbas/main.go Normal file
View file

@ -0,0 +1,75 @@
package main
import (
"flag"
"fmt"
"os"
)
type Executor interface {
Name() string
Parse([]string) error
Execute() error
}
func main() {
if err := run(); err != nil {
fmt.Printf("ERROR: %v.\n", err)
os.Exit(1)
}
}
func run() error {
const (
login string = "login"
version string = "version"
show string = "show"
switchAccount string = "switch"
)
summaries := map[string]string{
login: "login to an account on GoToSocial",
version: "print the application's version and build information",
show: "print details about a specified resource",
switchAccount: "switch to an account",
}
flag.Usage = enbasUsageFunc(summaries)
flag.Parse()
if flag.NArg() < 1 {
flag.Usage()
return nil
}
subcommand := flag.Arg(0)
args := flag.Args()[1:]
var executor Executor
switch subcommand {
case login:
executor = newLoginCommand(login, summaries[login])
case version:
executor = newVersionCommand(version, summaries[version])
case show:
executor = newShowCommand(show, summaries[show])
case switchAccount:
executor = newSwitchCommand(switchAccount, summaries[switchAccount])
default:
flag.Usage()
return fmt.Errorf("unknown subcommand %q", subcommand)
}
if err := executor.Parse(args); err != nil {
return fmt.Errorf("unable to parse the command line flags; %w", err)
}
if err := executor.Execute(); err != nil {
return fmt.Errorf("received an error after executing %q; %w", executor.Name(), err)
}
return nil
}

197
cmd/enbas/show.go Normal file
View file

@ -0,0 +1,197 @@
package main
import (
"errors"
"flag"
"fmt"
"strings"
"unicode"
"codeflow.dananglin.me.uk/apollo/enbas/internal/client"
"codeflow.dananglin.me.uk/apollo/enbas/internal/config"
"golang.org/x/net/html"
)
var instanceDetailsFormat = `INSTANCE:
%s - %s
DOMAIN:
%s
VERSION:
Running GoToSocial %s
CONTACT:
name: %s
username: %s
email: %s
`
var accountDetailsFormat = `
%s (@%s)
ACCOUNT ID:
%s
JOINED ON:
%s
STATS:
Followers: %d
Following: %d
Statuses: %d
BIOGRAPHY:
%s
METADATA: %s
ACCOUNT URL:
%s
`
type showCommand struct {
*flag.FlagSet
targetType string
account string
myAccount bool
}
func newShowCommand(name, summary string) *showCommand {
command := showCommand{
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
targetType: "",
myAccount: false,
}
command.StringVar(&command.targetType, "type", "", "specify the type of resource to display")
command.StringVar(&command.account, "account", "", "specify the account URI to lookup")
command.BoolVar(&command.myAccount, "my-account", false, "set to true to lookup your account")
command.Usage = commandUsageFunc(name, summary, command.FlagSet)
return &command
}
func (c *showCommand) Execute() error {
gtsClient, err := client.NewClientFromConfig()
if err != nil {
return fmt.Errorf("unable to create the GoToSocial client; %w", err)
}
funcMap := map[string]func(*client.Client) error{
"instance": c.showInstance,
"account": c.showAccount,
}
doFunc, ok := funcMap[c.targetType]
if !ok {
return fmt.Errorf("unsupported type %q", c.targetType)
}
return doFunc(gtsClient)
}
func (c *showCommand) showInstance(gts *client.Client) error {
instance, err := gts.GetInstance()
if err != nil {
return fmt.Errorf("unable to retrieve the instance details; %w", err)
}
fmt.Printf(
instanceDetailsFormat,
instance.Title,
instance.Description,
instance.Domain,
instance.Version,
instance.Contact.Account.DisplayName,
instance.Contact.Account.Username,
instance.Contact.Email,
)
return nil
}
func (c *showCommand) showAccount(gts *client.Client) error {
var accountURI string
if c.myAccount {
authConfig, err := config.NewAuthenticationConfigFromFile()
if err != nil {
return fmt.Errorf("unable to retrieve the authentication configuration; %w", err)
}
accountURI = authConfig.CurrentAccount
} else {
if c.account == "" {
return errors.New("the account flag is not set")
}
accountURI = c.account
}
account, err := gts.GetAccount(accountURI)
if err != nil {
return fmt.Errorf("unable to retrieve the account details; %w", err)
}
metadata := ""
for _, field := range account.Fields {
metadata += fmt.Sprintf("\n %s: %s", field.Name, stripHTMLTags(field.Value))
}
fmt.Printf(
accountDetailsFormat,
account.DisplayName,
account.Username,
account.ID,
account.CreatedAt.Format("02 Jan 2006"),
account.FollowersCount,
account.FollowingCount,
account.StatusCount,
wrapLine(stripHTMLTags(account.Note), "\n ", 80),
metadata,
account.URL,
)
return nil
}
func stripHTMLTags(text string) string {
token := html.NewTokenizer(strings.NewReader(text))
var builder strings.Builder
for {
tt := token.Next()
switch tt {
case html.ErrorToken:
return builder.String()
case html.TextToken:
builder.WriteString(token.Token().Data + " ")
}
}
}
func wrapLine(line, separator string, charLimit int) string {
if len(line) <= charLimit {
return line
}
leftcursor, rightcursor := 0, 0
var builder strings.Builder
for rightcursor < (len(line) - charLimit) {
rightcursor += charLimit
for !unicode.IsSpace(rune(line[rightcursor-1])) {
rightcursor--
}
builder.WriteString(line[leftcursor:rightcursor] + separator)
leftcursor = rightcursor
}
builder.WriteString(line[rightcursor:])
return builder.String()
}

35
cmd/enbas/switch.go Normal file
View file

@ -0,0 +1,35 @@
package main
import (
"flag"
"fmt"
"codeflow.dananglin.me.uk/apollo/enbas/internal/config"
)
type switchCommand struct {
*flag.FlagSet
toAccount string
}
func newSwitchCommand(name, summary string) *switchCommand {
command := switchCommand{
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
toAccount: "",
}
command.StringVar(&command.toAccount, "to-account", "", "the account to switch to")
command.Usage = commandUsageFunc(name, summary, command.FlagSet)
return &command
}
func (c *switchCommand) Execute() error {
if err := config.UpdateCurrentAccount(c.toAccount); err != nil {
return fmt.Errorf("unable to switch accounts; %w", err)
}
fmt.Printf("The current account is now set to %q.\n", c.toAccount)
return nil
}

76
cmd/enbas/usage.go Normal file
View file

@ -0,0 +1,76 @@
package main
import (
"flag"
"fmt"
"slices"
"strings"
)
func commandUsageFunc(name, summary string, flagset *flag.FlagSet) func() {
return func() {
var builder strings.Builder
fmt.Fprintf(
&builder,
"SUMMARY:\n %s - %s\n\nUSAGE:\n enbas %s [flags]\n\nFLAGS:",
name,
summary,
name,
)
flagset.VisitAll(func(f *flag.Flag) {
fmt.Fprintf(
&builder,
"\n -%s, --%s\n %s",
f.Name,
f.Name,
f.Usage,
)
})
builder.WriteString("\n")
w := flag.CommandLine.Output()
fmt.Fprint(w, builder.String())
}
}
func enbasUsageFunc(summaries map[string]string) func() {
cmds := make([]string, len(summaries))
ind := 0
for k := range summaries {
cmds[ind] = k
ind++
}
slices.Sort(cmds)
return func() {
var builder strings.Builder
builder.WriteString("SUMMARY:\n enbas - A GoToSocial client for the terminal.\n\n")
if binaryVersion != "" {
builder.WriteString("VERSION:\n " + binaryVersion + "\n\n")
}
builder.WriteString("USAGE:\n enbas [flags]\n enbas [command]\n\nCOMMANDS:")
for _, cmd := range cmds {
fmt.Fprintf(&builder, "\n %s\t%s", cmd, summaries[cmd])
}
builder.WriteString("\n\nFLAGS:\n -help, --help\n print the help message\n")
flag.VisitAll(func(f *flag.Flag) {
fmt.Fprintf(&builder, "\n -%s, --%s\n %s\n", f.Name, f.Name, f.Usage)
})
builder.WriteString("\nUse \"enbas [command] --help\" for more information about a command.\n")
w := flag.CommandLine.Output()
fmt.Fprint(w, builder.String())
}
}

62
cmd/enbas/version.go Normal file
View file

@ -0,0 +1,62 @@
package main
import (
"flag"
"fmt"
"os"
"strings"
)
var (
binaryVersion string
buildTime string
goVersion string
gitCommit string
)
type versionCommand struct {
*flag.FlagSet
showFullVersion bool
binaryVersion string
buildTime string
goVersion string
gitCommit string
}
func newVersionCommand(name, summary string) *versionCommand {
command := versionCommand{
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
binaryVersion: binaryVersion,
buildTime: buildTime,
goVersion: goVersion,
gitCommit: gitCommit,
showFullVersion: false,
}
command.BoolVar(&command.showFullVersion, "full", false, "prints the full build information")
command.Usage = commandUsageFunc(name, summary, command.FlagSet)
return &command
}
func (c *versionCommand) Execute() error {
var builder strings.Builder
if c.showFullVersion {
fmt.Fprintf(
&builder,
"Enbas\n Version: %s\n Git commit: %s\n Go version: %s\n Build date: %s\n",
c.binaryVersion,
c.gitCommit,
c.goVersion,
c.buildTime,
)
} else {
fmt.Fprintln(&builder, c.binaryVersion)
}
fmt.Fprint(os.Stdout, builder.String())
return nil
}

13
go.mod
View file

@ -1,3 +1,14 @@
module enbas
module codeflow.dananglin.me.uk/apollo/enbas
go 1.22.0
require (
golang.org/x/net v0.21.0
golang.org/x/oauth2 v0.17.0
)
require (
github.com/golang/protobuf v1.5.3 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.31.0 // indirect
)

24
go.sum Normal file
View file

@ -0,0 +1,24 @@
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ=
golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=

View file

@ -5,6 +5,8 @@ package main
import (
"fmt"
"os"
"runtime"
"time"
"github.com/magefile/mage/sh"
)
@ -51,8 +53,8 @@ func Build() error {
return fmt.Errorf("unable to change to the project's root directory; %w", err)
}
main := "main.go"
return sh.Run("go", "build", "-o", binary, main)
flags := ldflags()
return sh.Run("go", "build", "-ldflags="+flags, "-a", "-o", binary, "./cmd/enbas")
}
// Clean clean the workspace.
@ -79,3 +81,31 @@ func changeToProjectRoot() error {
return nil
}
// ldflags returns the build flags.
func ldflags() string {
ldflagsfmt := "-s -w -X main.binaryVersion=%s -X main.gitCommit=%s -X main.goVersion=%s -X main.buildTime=%s"
buildTime := time.Now().UTC().Format(time.RFC3339)
return fmt.Sprintf(ldflagsfmt, version(), gitCommit(), runtime.Version(), buildTime)
}
// version returns the latest git tag using git describe.
func version() string {
version, err := sh.Output("git", "describe", "--tags")
if err != nil {
version = "N/A"
}
return version
}
// gitCommit returns the current git commit
func gitCommit() string {
commit, err := sh.Output("git", "rev-parse", "--short", "HEAD")
if err != nil {
commit = "N/A"
}
return commit
}

125
internal/client/client.go Normal file
View file

@ -0,0 +1,125 @@
package client
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"codeflow.dananglin.me.uk/apollo/enbas/internal"
"codeflow.dananglin.me.uk/apollo/enbas/internal/config"
"codeflow.dananglin.me.uk/apollo/enbas/internal/model"
)
type Client struct {
Authentication config.Authentication
HTTPClient http.Client
UserAgent string
Timeout time.Duration
}
func NewClientFromConfig() (*Client, error) {
config, err := config.NewAuthenticationConfigFromFile()
if err != nil {
return nil, fmt.Errorf("unable to get the authentication configuration; %w", err)
}
currentAuthentication := config.Authentications[config.CurrentAccount]
return NewClient(currentAuthentication), nil
}
func NewClient(authentication config.Authentication) *Client {
httpClient := http.Client{}
client := Client{
Authentication: authentication,
HTTPClient: httpClient,
UserAgent: internal.UserAgent,
Timeout: 5 * time.Second,
}
return &client
}
func (g *Client) VerifyCredentials() (model.Account, error) {
path := "/api/v1/accounts/verify_credentials"
url := g.Authentication.Instance + path
var account model.Account
if err := g.sendRequest(http.MethodGet, url, nil, &account); err != nil {
return model.Account{}, fmt.Errorf("received an error after sending the request to verify the credentials; %w", err)
}
return account, nil
}
func (g *Client) GetInstance() (model.InstanceV2, error) {
path := "/api/v2/instance"
url := g.Authentication.Instance + path
var instance model.InstanceV2
if err := g.sendRequest(http.MethodGet, url, nil, &instance); err != nil {
return model.InstanceV2{}, fmt.Errorf("received an error after sending the request to get the instance details; %w", err)
}
return instance, nil
}
func (g *Client) GetAccount(accountURI string) (model.Account, error) {
path := "/api/v1/accounts/lookup"
url := g.Authentication.Instance + path + "?acct=" + accountURI
var account model.Account
if err := g.sendRequest(http.MethodGet, url, nil, &account); err != nil {
return model.Account{}, fmt.Errorf("received an error after sending the request to get the account information; %w", err)
}
return account, nil
}
func (g *Client) sendRequest(method string, url string, requestBody io.Reader, object any) error {
ctx, cancel := context.WithTimeout(context.Background(), g.Timeout)
defer cancel()
request, err := http.NewRequestWithContext(ctx, method, url, requestBody)
if err != nil {
return fmt.Errorf("unable to create the HTTP request, %w", err)
}
request.Header.Set("Content-Type", "application/json; charset=utf-8")
request.Header.Set("Accept", "application/json; charset=utf-8")
request.Header.Set("User-Agent", g.UserAgent)
if len(g.Authentication.AccessToken) > 0 {
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", g.Authentication.AccessToken))
}
response, err := g.HTTPClient.Do(request)
if err != nil {
return fmt.Errorf("received an error after sending the request; %w", err)
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusBadRequest {
return fmt.Errorf(
"did not receive an OK response from the GoToSocial server; got %d",
response.StatusCode,
)
}
if err := json.NewDecoder(response.Body).Decode(object); err != nil {
return fmt.Errorf(
"unable to decode the response from the GoToSocial server; %w",
err,
)
}
return nil
}

View file

@ -0,0 +1,48 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"codeflow.dananglin.me.uk/apollo/enbas/internal"
"codeflow.dananglin.me.uk/apollo/enbas/internal/model"
)
type RegisterRequest struct {
ClientName string `json:"client_name"`
RedirectUris string `json:"redirect_uris"`
Scopes string `json:"scopes"`
Website string `json:"website"`
}
func (g *Client) Register() error {
params := RegisterRequest{
ClientName: internal.ApplicationName,
RedirectUris: internal.RedirectUri,
Scopes: "read write",
Website: internal.ApplicationWebsite,
}
data, err := json.Marshal(params)
if err != nil {
return fmt.Errorf("unable to marshal the request body; %w", err)
}
requestBody := bytes.NewBuffer(data)
path := "/api/v1/apps"
url := g.Authentication.Instance + path
var app model.Application
if err := g.sendRequest(http.MethodPost, url, requestBody, &app); err != nil {
return fmt.Errorf("received an error after sending the registration request; %w", err)
}
g.Authentication.ClientID = app.ClientID
g.Authentication.ClientSecret = app.ClientSecret
return nil
}

151
internal/config/config.go Normal file
View file

@ -0,0 +1,151 @@
package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"codeflow.dananglin.me.uk/apollo/enbas/internal"
)
type AuthenticationConfig struct {
CurrentAccount string `json:"currentAccount"`
Authentications map[string]Authentication `json:"authentications"`
}
type Authentication struct {
Instance string `json:"instance"`
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
AccessToken string `json:"accessToken"`
}
func SaveAuthentication(username string, authentication Authentication) (string, error) {
if err := ensureConfigDir(); err != nil {
return "", fmt.Errorf("unable to ensure the configuration directory; %w", err)
}
var authConfig AuthenticationConfig
filepath := authenticationConfigFile()
if _, err := os.Stat(filepath); err != nil {
if !errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("unknown error received when running stat on %s; %w", filepath, err)
}
authConfig.Authentications = make(map[string]Authentication)
} else {
authConfig, err = NewAuthenticationConfigFromFile()
if err != nil {
return "", fmt.Errorf("unable to retrieve the existing authentication configuration; %w", err)
}
}
instance := ""
if strings.HasPrefix(authentication.Instance, "https://") {
instance = strings.TrimPrefix(authentication.Instance, "https://")
} else if strings.HasPrefix(authentication.Instance, "http://") {
instance = strings.TrimPrefix(authentication.Instance, "http://")
}
authenticationName := username + "@" + instance
authConfig.CurrentAccount = authenticationName
authConfig.Authentications[authenticationName] = authentication
if err := saveAuthenticationFile(authConfig); err != nil {
return "", fmt.Errorf("unable to save the authentication configuration to file; %w", err)
}
return authenticationName, nil
}
func NewAuthenticationConfigFromFile() (AuthenticationConfig, error) {
path := authenticationConfigFile()
file, err := os.Open(path)
if err != nil {
return AuthenticationConfig{}, fmt.Errorf("unable to open %s, %w", path, err)
}
defer file.Close()
var authConfig AuthenticationConfig
if err := json.NewDecoder(file).Decode(&authConfig); err != nil {
return AuthenticationConfig{}, fmt.Errorf("unable to decode the JSON data; %w", err)
}
return authConfig, nil
}
func UpdateCurrentAccount(account string) error {
authConfig, err := NewAuthenticationConfigFromFile()
if err != nil {
return fmt.Errorf("unable to retrieve the existing authentication configuration; %w", err)
}
if _, ok := authConfig.Authentications[account]; !ok {
return fmt.Errorf("account %s is not found", account)
}
authConfig.CurrentAccount = account
if err := saveAuthenticationFile(authConfig); err != nil {
return fmt.Errorf("unable to save the authentication configuration to file; %w", err)
}
return nil
}
func authenticationConfigFile() string {
return filepath.Join(configDir(), "authentications.json")
}
func configDir() string {
rootDir, err := os.UserConfigDir()
if err != nil {
rootDir = "."
}
return filepath.Join(rootDir, internal.ApplicationName)
}
func ensureConfigDir() error {
dir := configDir()
if _, err := os.Stat(dir); err != nil {
if errors.Is(err, os.ErrNotExist) {
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("unable to create %s; %w", dir, err)
}
} else {
return fmt.Errorf("unknown error received when running stat on %s; %w", dir, err)
}
}
return nil
}
func saveAuthenticationFile(authConfig AuthenticationConfig) error {
file, err := os.Create(authenticationConfigFile())
if err != nil {
return fmt.Errorf("unable to open the config file; %w", err)
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
if err := encoder.Encode(authConfig); err != nil {
return fmt.Errorf("unable to save the JSON data to the authentication config file; %w", err)
}
return nil
}

8
internal/internal.go Normal file
View file

@ -0,0 +1,8 @@
package internal
const (
ApplicationName = "enbas"
ApplicationWebsite = "https://codeflow.dananglin.me.uk/apollo/enbas"
RedirectUri = "urn:ietf:wg:oauth:2.0:oob"
UserAgent = "Enbas/0.0.0"
)

52
internal/model/account.go Normal file
View file

@ -0,0 +1,52 @@
package model
import "time"
type Account struct {
Acct string `json:"acct"`
Avatar string `json:"avatar"`
AvatarStatic string `json:"avatar_static"`
Bot bool `json:"bot"`
CreatedAt time.Time `json:"created_at"`
CustomCSS string `json:"custom_css"`
Discoverable bool `json:"discoverable"`
DisplayName string `json:"display_name"`
Emojis []Emoji `json:"emojis"`
EnableRSS bool `json:"enable_rss"`
Fields []Field `json:"fields"`
FollowersCount int `json:"followers_count"`
FollowingCount int `json:"following_count"`
Header string `json:"header"`
HeaderStatic string `json:"header_static"`
ID string `json:"id"`
LastStatusAt string `json:"last_status_at"`
Locked bool `json:"locked"`
MuteExpiresAt time.Time `json:"mute_expires_at"`
Note string `json:"note"`
Role AccountRole `json:"role"`
Source Source `json:"source"`
StatusCount int `json:"statuses_count"`
Suspended bool `json:"suspended"`
URL string `json:"url"`
Username string `json:"username"`
}
type AccountRole struct {
Name string `json:"name"`
}
type Source struct {
Fields []Field `json:"fields"`
FollowRequestCount int `json:"follow_requests_count"`
Language string `json:"language"`
Note string `json:"note"`
Privacy string `json:"string"`
Sensitive bool `json:"sensitive"`
StatusContentType string `json:"status_content_type"`
}
type Field struct {
Name string `json:"name"`
Value string `json:"value"`
VerifiedAt string `json:"verified_at"`
}

View file

@ -0,0 +1,11 @@
package model
type Application struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
ID string `json:"id"`
Name string `json:"name"`
RedirectUri string `json:"redirect_uri"`
VapidKey string `json:"vapid_key"`
Website string `json:"website"`
}

9
internal/model/emoji.go Normal file
View file

@ -0,0 +1,9 @@
package model
type Emoji struct {
Category string `json:"category"`
Shortcode string `json:"shortcode"`
StaticURL string `json:"static_url"`
URL string `json:"url"`
VisibleInPicker bool `json:"visible_in_picker"`
}

View file

@ -0,0 +1,106 @@
package model
type InstanceV2 struct {
AccountDomain string `json:"account_domain"`
Configuration InstanceConfiguration `json:"configuration"`
Contact InstanceV2Contact `json:"contact"`
Description string `json:"description"`
Domain string `json:"domain"`
Languages []string `json:"languages"`
Registrations InstanceV2Registrations `json:"registrations"`
Rules []InstanceRule `json:"rules"`
SourceURL string `json:"source_url"`
Terms string `json:"terms"`
Thumbnail InstanceV2Thumbnail `json:"thumbnail"`
Title string `json:"title"`
Usage InstanceV2Usage `json:"usage"`
Version string `json:"version"`
}
type InstanceConfiguration struct {
Accounts InstanceConfigurationAccounts `json:"accounts"`
Emojis InstanceConfigurationEmojis `json:"emojis"`
MediaAttachments InstanceConfigurationMediaAttachments `json:"media_attachments"`
Polls InstanceConfigurationPolls `json:"polls"`
Statuses InstanceConfigurationStatuses `json:"statuses"`
Translation InstanceV2ConfigurationTranslation `json:"translation"`
URLs InstanceV2URLs `json:"urls"`
}
type InstanceConfigurationAccounts struct {
AllowCustomCSS bool `json:"allow_custom_css"`
MaxFeaturedTags int `json:"max_featured_tags"`
MaxProfileFields int `json:"max_profile_fields"`
}
type InstanceConfigurationEmojis struct {
EmojiSizeLimit int `json:"emoji_size_limit"`
}
type InstanceConfigurationMediaAttachments struct {
ImageMatrixLimit int `json:"image_matrix_limit"`
ImageSizeLimit int `json:"image_size_limit"`
SupportedMimeTypes []string `json:"supported_mime_types"`
VideoFrameRateLimit int `json:"video_frame_rate_limit"`
VideoMatrixLimit int `json:"video_matrix_limit"`
VideoSizeLimit int `json:"video_size_limit"`
}
type InstanceConfigurationPolls struct {
MaxCharactersPerOption int `json:"max_characters_per_option"`
MaxExpiration int `json:"max_expiration"`
MaxOptions int `json:"max_options"`
MinExpiration int `json:"min_expiration"`
}
type InstanceConfigurationStatuses struct {
CharactersReservedPerURL int `json:"characters_reserved_per_url"`
MaxCharacters int `json:"max_characters"`
MaxMediaAttachments int `json:"max_media_attachments"`
SupportedMimeTypes []string `json:"supported_mime_types"`
}
type InstanceV2ConfigurationTranslation struct {
Enabled bool `json:"enabled"`
}
type InstanceV2URLs struct {
Streaming string `json:"streaming"`
}
type InstanceV2Contact struct {
Account Account `json:"account"`
Email string `json:"email"`
}
type InstanceV2Registrations struct {
ApprovalRequired bool `json:"approval_required"`
Enabled bool `json:"enabled"`
Message string `json:"message"`
}
type InstanceRule struct {
ID string `json:"id"`
Text string `json:"text"`
}
type InstanceV2Thumbnail struct {
BlurHash string `json:"blurhash"`
ThumbnailDescription string `json:"thumbnail_description"`
ThumbnailType string `json:"thumbnail_type"`
URL string `json:"url"`
Version InstanceV2ThumbnailVersions `json:"versions"`
}
type InstanceV2ThumbnailVersions struct {
Size1URL string `json:"@1x"`
Size2URL string `json:"@2x"`
}
type InstanceV2Usage struct {
Users InstanceV2Users `json:"users"`
}
type InstanceV2Users struct {
ActiveMonth int `json:"active_month"`
}

148
internal/model/status.go Normal file
View file

@ -0,0 +1,148 @@
package model
import "time"
type Status struct {
Account Account `json:"account"`
Application Application `json:"application"`
Bookmarked bool `json:"bookmarked"`
Card Card `json:"card"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
Emojis []Emoji `json:"emojis"`
Favourited bool `json:"favourited"`
FavouritesCount int `json:"favourites_count"`
ID string `json:"id"`
InReplyToAccountID string `json:"in_reply_to_account_id"`
InReplyToID string `json:"in_reply_to_id"`
Language string `json:"language"`
MediaAttachments []Attachment `json:"media_attachments"`
Mentions []Mention `json:"mentions"`
Muted bool `json:"muted"`
Pinned bool `json:"pinned"`
Poll Poll `json:"poll"`
Reblog StatusReblogged `json:"reblog"`
Reblogged bool `json:"reblogged"`
RebloggsCount int `json:"reblogs_count"`
RepliesCount int `json:"replies_count"`
Sensitive bool `json:"sensitive"`
SpolierText string `json:"spoiler_text"`
Tags []Tag `json:"tags"`
Text string `json:"text"`
URI string `json:"uri"`
URL string `json:"url"`
Visibility string `json:"visibility"`
}
type Card struct {
AuthorName string `json:"author_name"`
AuthorURL string `json:"author_url"`
Blurhash string `json:"blurhash"`
Description string `json:"description"`
EmbedURL string `json:"embed_url"`
HTML string `json:"html"`
Image string `json:"image"`
ProviderName string `json:"provider_name"`
ProviderURL string `json:"provider_url"`
Title string `json:"title"`
Type string `json:"type"`
URL string `json:"url"`
Height int `json:"height"`
Width int `json:"width"`
}
type Mention struct {
Acct string `json:"acct"`
ID string `json:"id"`
URL string `json:"url"`
Username string `json:"username"`
}
type Poll struct {
Emojis []Emoji `json:"emojis"`
Expired bool `json:"expired"`
Voted bool `json:"voted"`
Multiple bool `json:"multiple"`
ExpiredAt time.Time `json:"expires_at"`
ID string `json:"id"`
OwnVotes []int `json:"own_votes"`
VotersCount int `json:"voters_count"`
VotesCount int `json:"votes_count"`
Options []PollOption `json:"options"`
}
type PollOption struct {
Title string `json:"title"`
VotesCount string `json:"votes_count"`
}
type StatusReblogged struct {
Account Account `json:"account"`
Application Application `json:"application"`
Bookmarked bool `json:"bookmarked"`
Card Card `json:"card"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
Emojis []Emoji `json:"emojis"`
Favourited bool `json:"favourited"`
FavouritesCount int `json:"favourites_count"`
ID string `json:"id"`
InReplyToAccountID string `json:"in_reply_to_account_id"`
InReplyToID string `json:"in_reply_to_id"`
Language string `json:"language"`
MediaAttachments []Attachment `json:"media_attachments"`
Mentions []Mention `json:"mentions"`
Muted bool `json:"muted"`
Pinned bool `json:"pinned"`
Poll Poll `json:"poll"`
Reblogged bool `json:"reblogged"`
RebloggsCount int `json:"reblogs_count"`
RepliesCount int `json:"replies_count"`
Sensitive bool `json:"sensitive"`
SpolierText string `json:"spoiler_text"`
Tags []Tag `json:"tags"`
Text string `json:"text"`
URI string `json:"uri"`
URL string `json:"url"`
Visibility string `json:"visibility"`
}
type Tag struct {
History []any `json:"history"`
Name string `json:"name"`
URL string `json:"url"`
}
type Attachment struct {
Meta MediaMeta `json:"meta"`
Blurhash string `json:"blurhash"`
Description string `json:"description"`
ID string `json:"id"`
PreviewRemoteURL string `json:"preview_remote_url"`
PreviewURL string `json:"preview_url"`
RemoteURL string `json:"remote_url"`
TextURL string `json:"text_url"`
Type string `json:"type"`
URL string `json:"url"`
}
type MediaMeta struct {
Focus MediaFocus `json:"focus"`
Original MediaDimensions `json:"original"`
Small MediaDimensions `json:"small"`
}
type MediaFocus struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
type MediaDimensions struct {
Aspect float64 `json:"aspect"`
Bitrate int `json:"bitrate"`
Duration float64 `json:"duration"`
FrameRate string `json:"frame_rate"`
Size string `json:"size"`
Height int `json:"height"`
Width int `json:"width"`
}

View file

@ -1,7 +0,0 @@
package main
import "fmt"
func main() {
fmt.Println("Hello, Enbas!")
}