package main import ( "bytes" "context" "encoding/gob" "errors" "flag" "fmt" "io" "net/http" "os" "time" "github.com/google/subcommands" "gitlab.com/rhogenson/roseh.moe/internal/api" "golang.org/x/term" ) var serverURL = flag.String("url", "https://roseh.moe", "server url") const tokenCache = "/dev/shm/roseh.moe-upload-token" var errUnavailable = errors.New("unavailable") func readGobResp[Response any](req *http.Request) (*Response, error) { httpResp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("%w: read gob response: http: %s", errUnavailable, err) } defer httpResp.Body.Close() if httpResp.StatusCode != http.StatusOK { body, _ := io.ReadAll(httpResp.Body) return nil, fmt.Errorf("read gob response: error status: %s\n%s", httpResp.Status, body) } apiResp := new(api.Response) if err := gob.NewDecoder(httpResp.Body).Decode(apiResp); err != nil { return nil, fmt.Errorf("read gob response: decode response: %s", err) } if apiResp.Status != api.Ok { return nil, fmt.Errorf("read gob response: api error: %s: %s", apiResp.Status, apiResp.Err) } resp, ok := apiResp.Ok.(*Response) if !ok { return nil, fmt.Errorf("read gob response: invalid response") } return resp, nil } func login(ctx context.Context) (*api.LoginResponse, error) { fmt.Fprint(os.Stderr, "Enter password:") password, err := term.ReadPassword(int(os.Stdin.Fd())) fmt.Fprintln(os.Stderr) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, "POST", *serverURL+"/api/login", nil) if err != nil { return nil, err } req.Header.Set("Roseh-Password", string(password)) return readGobResp[api.LoginResponse](req) } func loadToken(ctx context.Context) (string, error) { token, err := os.ReadFile(tokenCache) if err != nil { if errors.Is(err, os.ErrNotExist) { token, err := login(ctx) if err != nil { return "", err } os.WriteFile(tokenCache, []byte(token.Token), 0600) return token.Token, nil } return "", err } return string(token), nil } func gobReq(ctx context.Context, method, url string, req any) (*http.Request, error) { buf := new(bytes.Buffer) if err := gob.NewEncoder(buf).Encode(req); err != nil { return nil, err } httpReq, err := http.NewRequestWithContext(ctx, method, url, buf) if err != nil { return nil, err } token, err := loadToken(ctx) if err != nil { return nil, err } httpReq.Header.Set("Roseh-Token", token) return httpReq, nil } func gobReqResp[Response any](ctx context.Context, method, url string, req any) (*Response, error) { httpReq, err := gobReq(ctx, method, url, req) if err != nil { return nil, err } return readGobResp[Response](httpReq) } func listNotes(ctx context.Context, req *api.ListNotesRequest) (*api.ListNotesResponse, error) { return gobReqResp[api.ListNotesResponse](ctx, "GET", *serverURL+"/api/list-notes", req) } func createNote(ctx context.Context, req *api.CreateNoteRequest) (*api.CreateNoteResponse, error) { return gobReqResp[api.CreateNoteResponse](ctx, "POST", *serverURL+"/api/create-note", req) } func readNote(ctx context.Context, req *api.ReadNoteRequest) (*gob.Decoder, func() error, error) { httpReq, err := gobReq(ctx, "GET", *serverURL+"/api/read-note", req) if err != nil { return nil, nil, err } resp, err := http.DefaultClient.Do(httpReq) if err != nil { return nil, nil, err } if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) resp.Body.Close() return nil, nil, fmt.Errorf("error status: %s\n%s", resp.Status, body) } return gob.NewDecoder(resp.Body), resp.Body.Close, nil } type loginCommand struct{} func (*loginCommand) Name() string { return "login" } func (*loginCommand) Synopsis() string { return "/api/login" } func (*loginCommand) Usage() string { return "makes a request to /api/login for debugging" } func (*loginCommand) SetFlags(*flag.FlagSet) {} func (*loginCommand) login(ctx context.Context) error { token, err := login(ctx) if err != nil { return err } return os.WriteFile(tokenCache, []byte(token.Token), 0600) } func (c *loginCommand) Execute(ctx context.Context, _ *flag.FlagSet, _ ...any) subcommands.ExitStatus { if err := c.login(ctx); err != nil { fmt.Fprintln(os.Stderr, err) return subcommands.ExitFailure } return subcommands.ExitSuccess } type listCommand struct{} func (*listCommand) Name() string { return "list" } func (*listCommand) Synopsis() string { return "list notes" } func (*listCommand) Usage() string { return "list all the notes" } func (*listCommand) SetFlags(*flag.FlagSet) {} func (*listCommand) list(ctx context.Context) error { resp, err := listNotes(ctx, &api.ListNotesRequest{}) if err != nil { return err } for _, note := range resp.Notes { fmt.Println(note) } return nil } func (c *listCommand) Execute(ctx context.Context, _ *flag.FlagSet, _ ...any) subcommands.ExitStatus { if err := c.list(ctx); err != nil { fmt.Fprintln(os.Stderr, err) return subcommands.ExitFailure } return subcommands.ExitSuccess } type newCommand struct{} func (*newCommand) Name() string { return "new" } func (*newCommand) Synopsis() string { return "create note" } func (*newCommand) Usage() string { return "upload a new note" } func (*newCommand) SetFlags(*flag.FlagSet) {} type createNoteRequestStreamWriter struct { ctx context.Context continuationToken []byte } func (w *createNoteRequestStreamWriter) Write(buf []byte) (int, error) { var ( resp *api.CreateNoteResponse err error ) delay := time.Second for i := 0; i < 3; i++ { resp, err = createNote(w.ctx, &api.CreateNoteRequest{ ContinuationToken: w.continuationToken, Chunk: buf, More: true, }) if !errors.Is(err, errUnavailable) { break } fmt.Fprintf(os.Stderr, "Warning: %s (retrying)\n", err) select { case <-time.After(delay): case <-w.ctx.Done(): return 0, w.ctx.Err() } delay *= 2 } if err != nil { return 0, err } w.continuationToken = resp.ContinuationToken return len(buf), nil } func (*newCommand) new(ctx context.Context, fileName string) error { f, err := os.Open(fileName) if err != nil { return err } defer f.Close() streamWriter := &createNoteRequestStreamWriter{ctx: ctx} if _, err := io.Copy(streamWriter, f); err != nil { return err } resp, err := createNote(ctx, &api.CreateNoteRequest{ContinuationToken: streamWriter.continuationToken}) if err != nil { return err } fmt.Println(resp.Name) return nil } func (c *newCommand) Execute(ctx context.Context, fs *flag.FlagSet, _ ...any) subcommands.ExitStatus { args := fs.Args() if len(args) != 1 { fmt.Fprintln(os.Stderr, "usage error") return subcommands.ExitUsageError } if err := c.new(ctx, args[0]); err != nil { fmt.Fprintln(os.Stderr, err) return subcommands.ExitFailure } return subcommands.ExitSuccess } type readCommand struct{} func (*readCommand) Name() string { return "read" } func (*readCommand) Synopsis() string { return "read a note" } func (*readCommand) Usage() string { return "read a note by name" } func (*readCommand) SetFlags(*flag.FlagSet) {} func (*readCommand) read(ctx context.Context, key string) error { decoder, close, err := readNote(ctx, &api.ReadNoteRequest{Note: key}) if err != nil { return err } defer close() for { apiResp := new(api.Response) if err := decoder.Decode(apiResp); err != nil { if errors.Is(err, io.EOF) { break } return err } if apiResp.Status != api.Ok { return fmt.Errorf("%s: %s", apiResp.Status, apiResp.Err) } resp, ok := apiResp.Ok.(*api.ReadNoteResponseStream) if !ok { return fmt.Errorf("invalid response") } fmt.Printf("%s", resp.Chunk) } return nil } func (c *readCommand) Execute(ctx context.Context, fs *flag.FlagSet, _ ...any) subcommands.ExitStatus { args := fs.Args() if len(args) != 1 { fmt.Fprintln(os.Stderr, "usage error") return subcommands.ExitUsageError } if err := c.read(ctx, args[0]); err != nil { fmt.Fprintln(os.Stderr, err) return subcommands.ExitFailure } return subcommands.ExitSuccess } func main() { subcommands.ImportantFlag("url") subcommands.Register(subcommands.HelpCommand(), "") subcommands.Register(subcommands.FlagsCommand(), "") subcommands.Register(subcommands.CommandsCommand(), "") subcommands.Register(&loginCommand{}, "") subcommands.Register(&listCommand{}, "") subcommands.Register(&newCommand{}, "") subcommands.Register(&readCommand{}, "") flag.Parse() os.Exit(int(subcommands.Execute(context.Background()))) }