Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `internal/ftpuser` read module and `kasapi-cli ftpusers list|get`
subcommand tree wrapping `get_ftpusers`. The list variant decodes
the Array of Maps into a typed `FTPUserList`; `get <ftp-login>`
reuses the same endpoint with an `ftp_login` filter and unwraps the
single-entry result. The list view shows login, path, comment,
main-user flag, the three permission flags (R/W/L), the ClamAV
scan flag, and `in_progress`; the singular view falls back to a
key/value table and omits `ftp_password` / `ftp_passwort` (still
available via `--output=json|yaml`). Mapping tests run against
`testdata/ftpuser/get_ftpusers_response_success.xml`,
`get_ftpuser_response_success.xml`, and the empty-list fixture
(`get_ftpuser_response_success_empty_list.xml`). Refs #11.

- `internal/database` read module and `kasapi-cli databases list|get`
subcommand tree wrapping `get_databases`. The list variant decodes
the Array of Maps into a typed `DatabaseList`; `get <database-login>`
Expand Down
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ The list is kept in sync with the code on `main`. To claim an unchecked item, pl

- [x] `databases list` / `databases get <database-login>` (`get_databases`, with `database_login` filter)
- [ ] Database write paths (`add_database`, `update_database`, `delete_database`)
- [ ] FTP users (`get_ftpusers`, `add_ftpuser`, `update_ftpuser`, `delete_ftpuser`)
- [x] `ftpusers list` / `ftpusers get <ftp-login>` (`get_ftpusers`, with `ftp_login` filter)
- [ ] FTP user write paths (`add_ftpuser`, `update_ftpuser`, `delete_ftpuser`)
- [ ] Samba users (`get_sambausers`, `add_sambauser`, `update_sambauser`, `delete_sambauser`)
- [ ] DDNS users (`get_ddnsusers`, `add_ddnsuser`, `update_ddnsuser`, `delete_ddnsuser`)
- [ ] Cronjobs (`get_cronjobs`, `add_cronjob`, `update_cronjob`, `delete_cronjob`)
Expand Down
1 change: 1 addition & 0 deletions cmd/kasapi-cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ func main() {
cli.NewDNSCmd(opts),
cli.NewMailCmd(opts),
cli.NewDatabasesCmd(opts),
cli.NewFTPUsersCmd(opts),
cli.NewUsageCmd(opts),
cli.NewConfigCmd(opts),
)
Expand Down
66 changes: 66 additions & 0 deletions internal/cli/ftpusers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package cli

import (
"github.com/spf13/cobra"

"github.com/chmmou/kasapi-cli/internal/ftpuser"
)

// NewFTPUsersCmd returns the "kasapi-cli ftpusers" subcommand tree:
// list (get_ftpusers, no filter) and get <ftp-login> (get_ftpusers
// with an ftp_login filter).
func NewFTPUsersCmd(opts *RootOptions) *cobra.Command {
cmd := &cobra.Command{
Use: "ftpusers",
Short: "Inspect FTP users visible to the login (get_ftpusers)",
}
cmd.AddCommand(
newFTPUsersListCmd(opts),
newFTPUsersGetCmd(opts),
)
return cmd
}

func newFTPUsersListCmd(opts *RootOptions) *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all FTP users (get_ftpusers, no filter)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
api, err := BuildAPIClient(opts)
if err != nil {
return err
}
list, err := ftpuser.NewClient(api).List(cmd.Context())
if err != nil {
return APIError(err, "get_ftpusers")
}
if err := Render(cmd.OutOrStdout(), opts.Output, list); err != nil {
return UserError(err, "render")
}
return nil
},
}
}

func newFTPUsersGetCmd(opts *RootOptions) *cobra.Command {
return &cobra.Command{
Use: "get <ftp-login>",
Short: "Show details for a single FTP user (get_ftpusers with ftp_login)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
api, err := BuildAPIClient(opts)
if err != nil {
return err
}
u, err := ftpuser.NewClient(api).Get(cmd.Context(), args[0])
if err != nil {
return APIError(err, "get_ftpusers")
}
if err := Render(cmd.OutOrStdout(), opts.Output, u); err != nil {
return UserError(err, "render")
}
return nil
},
}
}
29 changes: 29 additions & 0 deletions internal/cli/ftpusers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package cli_test

import (
"bytes"
"strings"
"testing"

"github.com/chmmou/kasapi-cli/internal/cli"
)

func TestFTPUsersCmdHelpListsSubcommands(t *testing.T) {
t.Parallel()
root, opts := cli.NewRootCmd()
root.AddCommand(cli.NewFTPUsersCmd(opts))

var buf bytes.Buffer
root.SetOut(&buf)
root.SetErr(&buf)
root.SetArgs([]string{"ftpusers", "--help"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
out := buf.String()
for _, want := range []string{"list", "get"} {
if !strings.Contains(out, want) {
t.Errorf("--help output missing %q\n%s", want, out)
}
}
}
174 changes: 174 additions & 0 deletions internal/ftpuser/ftpuser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package ftpuser

import (
"context"
"fmt"

"github.com/chmmou/kasapi-cli/internal/soap"
)

// Caller is the subset of *api.Client this package depends on. The
// indirection keeps tests free of network setup: a fake Caller can
// return a *soap.Response decoded from a fixture.
type Caller interface {
Call(ctx context.Context, action string, params map[string]any) (*soap.Response, error)
}

// FTPUser is one entry of get_ftpusers. List and singular views (the
// latter being get_ftpusers called with an ftp_login filter) return
// the same Map shape, so a single struct covers both.
//
// The KAS API returns both the canonical ftp_password and the legacy
// German ftp_passwort key with identical content; both are kept in
// the struct so a JSON/YAML round-trip preserves the raw payload.
// Consumers should prefer Password.
type FTPUser struct {
Login string `json:"ftp_login" yaml:"ftp_login"`
Password string `json:"ftp_password,omitempty" yaml:"ftp_password,omitempty"`
Passwort string `json:"ftp_passwort,omitempty" yaml:"ftp_passwort,omitempty"`

Path string `json:"ftp_path" yaml:"ftp_path"`
Comment string `json:"ftp_comment" yaml:"ftp_comment"`
IsMainUser string `json:"ftp_is_main_user" yaml:"ftp_is_main_user"`

PermissionList string `json:"ftp_permission_list" yaml:"ftp_permission_list"`
PermissionRead string `json:"ftp_permission_read" yaml:"ftp_permission_read"`
PermissionWrite string `json:"ftp_permission_write" yaml:"ftp_permission_write"`

VirusClamAV string `json:"ftp_virus_clamav" yaml:"ftp_virus_clamav"`
InProgress string `json:"in_progress" yaml:"in_progress"`
}

// FTPUserList is the typed payload of get_ftpusers; satisfies
// cli.Tabular.
type FTPUserList []FTPUser

// Client groups the read endpoints scoped to FTP users:
// get_ftpusers (list and singular).
type Client struct {
API Caller
}

// NewClient returns a Client backed by the given Caller.
func NewClient(c Caller) *Client { return &Client{API: c} }

// List calls get_ftpusers without parameters and decodes the response
// into an FTPUserList covering every FTP user visible to the login.
func (c *Client) List(ctx context.Context) (FTPUserList, error) {
resp, err := c.API.Call(ctx, "get_ftpusers", nil)
if err != nil {
return nil, err
}
list, err := DecodeFTPUsers(resp.Body.ReturnInfo)
if err != nil {
return nil, fmt.Errorf("ftpuser: get_ftpusers: %w", err)
}
return list, nil
}

// Get calls get_ftpusers with an ftp_login filter and returns the
// single matching FTPUser. The KAS API still wraps the result in an
// array; we unwrap it here so callers do not have to. An empty array
// surfaces as a not-found error.
func (c *Client) Get(ctx context.Context, login string) (FTPUser, error) {
if login == "" {
return FTPUser{}, fmt.Errorf("ftpuser: login is required")
}
resp, err := c.API.Call(ctx, "get_ftpusers", map[string]any{"ftp_login": login})
if err != nil {
return FTPUser{}, err
}
list, err := DecodeFTPUsers(resp.Body.ReturnInfo)
if err != nil {
return FTPUser{}, fmt.Errorf("ftpuser: get_ftpusers: %w", err)
}
if len(list) == 0 {
return FTPUser{}, fmt.Errorf("ftpuser: %q not found", login)
}
return list[0], nil
}

// DecodeFTPUsers maps the ReturnInfo of a get_ftpusers response (an
// Array of Maps) into the typed FTPUserList.
func DecodeFTPUsers(returnInfo soap.Value) (FTPUserList, error) {
if returnInfo.Kind != soap.KindArray {
return nil, fmt.Errorf("ftpuser: expected ReturnInfo array, got kind %d", returnInfo.Kind)
}
out := make(FTPUserList, 0, len(returnInfo.Array))
for i, item := range returnInfo.Array {
if item.Kind != soap.KindMap {
return nil, fmt.Errorf("ftpuser: ReturnInfo[%d] is not a Map", i)
}
out = append(out, FTPUser{
Login: getString(item, "ftp_login"),
Password: getString(item, "ftp_password"),
Passwort: getString(item, "ftp_passwort"),
Path: getString(item, "ftp_path"),
Comment: getString(item, "ftp_comment"),
IsMainUser: getString(item, "ftp_is_main_user"),
PermissionList: getString(item, "ftp_permission_list"),
PermissionRead: getString(item, "ftp_permission_read"),
PermissionWrite: getString(item, "ftp_permission_write"),
VirusClamAV: getString(item, "ftp_virus_clamav"),
InProgress: getString(item, "in_progress"),
})
}
return out, nil
}

func getString(m soap.Value, key string) string {
v, ok := m.Get(key)
if !ok {
return ""
}
return v.AsString()
}

// TableHeaders returns the columns used by --output=table for
// FTPUserList.
func (FTPUserList) TableHeaders() []string {
return []string{"LOGIN", "PATH", "COMMENT", "MAIN", "R", "W", "L", "CLAMAV", "IN_PROGRESS"}
}

// TableRows emits one row per FTPUser entry. The R/W/L columns mirror
// the KAS UI layout for the three permission flags
// (read/write/list-only) so a quick scan shows what each account can do.
func (l FTPUserList) TableRows() [][]string {
rows := make([][]string, 0, len(l))
for _, u := range l {
rows = append(rows, []string{
u.Login,
u.Path,
u.Comment,
u.IsMainUser,
u.PermissionRead,
u.PermissionWrite,
u.PermissionList,
u.VirusClamAV,
u.InProgress,
})
}
return rows
}

// TableHeaders for the singular FTPUser view: a key/value layout.
func (FTPUser) TableHeaders() []string {
return []string{"FIELD", "VALUE"}
}

// TableRows emits the scalar fields. ftp_password / ftp_passwort are
// intentionally omitted — consumers that need them should use
// --output=json|yaml.
func (u FTPUser) TableRows() [][]string {
return [][]string{
{"ftp_login", u.Login},
{"ftp_path", u.Path},
{"ftp_comment", u.Comment},
{"ftp_is_main_user", u.IsMainUser},
{"ftp_permission_read", u.PermissionRead},
{"ftp_permission_write", u.PermissionWrite},
{"ftp_permission_list", u.PermissionList},
{"ftp_virus_clamav", u.VirusClamAV},
{"in_progress", u.InProgress},
}
}
Loading
Loading