Building a Playful File Locker with GoFr
Tags: go, gofr
Published: 2024-04-19 10:38:42



Forget complex permission schemes, sometimes you just want a quick and dirty way to lock a file. This guide takes you on a fun adventure to create a basic file locker command-line tool using GoFr, specifically focusing on understanding CLI tool development rather than building a production-ready application.

## What is GoFr?
[GoFr](https://gofr.dev/) is an opinionated Go framework for accelerated microservice development. It takes an "opinionated" approach, meaning it has a specific way of doing things that streamlines development. This makes Gofr ideal for creating robust and scalable web applications without a lot of boilerplate code.

Gofr is designed to be familiar and user-friendly for developers, even those new to Go. It provides a clear and consistent way to structure your web application, making it easier to understand and maintain.

## Building Our Playful File Locker

### 1. Setting the Stage:

- Make sure you have Go installed [https://go.dev/](https://go.dev/).
- Create a project directory and initialize a Go module within it using go mod init **<project_name>**. Replace **<project_name>** with a fun name for your locker tool!
- Add [gofr](https://github.com/gofr-dev/gofr) package to the project using the following command:
  `go get gofr.dev`

### 2. Directory structure
```bash
├── file-locker
│   ├── handlers
│   │   ├── unix
│   │   │   ├── handler.go
│   ├── services
│   │   ├── unix
│   │   │   ├── service.go
│   │   ├── crypt
│   │   │   ├── crypt.go
│   ├── constants
│   │   ├── contants.go
│   ├── main.go
│   ├── go.mod
```

### 3. Service Layer
- Let's add interface for the service. Add following code to `services/interfaces.go`:

    ```go
    type FileLocker interface {  
        Init(password string) error  
        Lock() error  
        Unlock(password string) error  
    }
  
    type Crypt interface {  
        Encrypt(creds []byte) []byte  
        Decrypt(cred string) ([]byte, error)  
    }
    ```
- Let's add implementation for interfaces. Add following code to `services/unix/service.go`:
  ```go
  const (  
     fileName = `private`  
     hiddenFileName = `.private`  
     encryptedDataFileName = `.encrypted-data`  
  )  
    
  type unix struct {  
     crypt services.Crypt  
  }  
    
  func New(crypt services.Crypt) services.FileLocker {  
     return &unix{crypt: crypt}  
  }  
    
  func (m *unix) Init(password string) error {  
     _, err := os.Stat(fileName)  
     if err == nil {  
        return errors.New("file locker already initialized")  
     }  
    
     err = os.Mkdir(fileName, os.ModePerm)  
     if err != nil {  
        return err  
     }  
    
     _, err = os.Create(filepath.Join(fileName, ".nomedia"))  
     if err != nil {  
        return err  
     }  
    
     encryptedPassword := m.crypt.Encrypt([]byte(password))  
    
     err = os.WriteFile(filepath.Join(fileName, encryptedDataFileName), encryptedPassword, os.ModePerm)  
     if err != nil {  
        return err  
     }  
    
     return nil  
  }  
    
  func (m *unix) Lock() error {  
     _, err := os.Stat(fileName)  
     if err != nil {  
        if err == os.ErrNotExist {  
           return errors.New("file locker not initialized or is still locked")  
        }  
    
        return err  
     }  
    
     return os.Rename(fileName, hiddenFileName)  
  }  
    
  func (m *unix) Unlock(password string) error {  
     _, err := os.Stat(hiddenFileName)  
     if err != nil {  
        if err == os.ErrNotExist {  
           return errors.New("file locker not initialized or is still unlocked")  
        }  
    
        return err  
     }  
    
     data, err := os.ReadFile(filepath.Join(hiddenFileName, encryptedDataFileName))  
     if err != nil {  
        return err  
     }  
    
     decryptedData, err := m.crypt.Decrypt(string(data))  
     if err != nil {  
        return err  
     }  
    
     if password != string(decryptedData) {  
        return errors.New("unauthorized")  
     }  
    
     return os.Rename(hiddenFileName, fileName)  
  }
  ```
    - Struct `unix` implements `FileLocker` interface.
    - `New` is the factory function that returns a `FileLocker` instance
    - `Init` method will create a directory named `private` in the current directory and will store the encrypted password in file named `.encrypted-data`. If `private` directory is already present or there occurs some error while creating it then `Init` method will return error otherwise will return nil.
    - `Lock` method will hide the `private` directory by renaming it to `.private`. If the `private` directory is not present in the current directory then it means that either `file-locker` has not been initialised or it is already in locked state.
    - `Unlock` method will read the password from `.private/.encrypted-data` file and compare with the password received in the parameter. If it matches, then it will unhide the `private` file otherwise return `unauthorized` error.
- Add the following code to `services/crypt/crypt.go`:
  ```go
  const secret = `qwertyuiopasdfghjklzxcvb`  
    
  type crypt struct {  
     block cipher.Block  
  }  
    
  func New() (services.Crypt, error) {  
     block, err := aes.NewCipher([]byte(secret))  
     if err != nil {  
        return nil, err  
     }  
    
     return &crypt{block: block}, nil  
  }  
    
  func (c *crypt) Encrypt(data []byte) []byte {  
     ciphertext := make([]byte, aes.BlockSize+len(data))  
     iv := ciphertext[:aes.BlockSize]  
    
     cfb := cipher.NewCFBEncrypter(c.block, iv)  
     cipherText := make([]byte, len(data))  
     cfb.XORKeyStream(cipherText, data)  
    
     dst := make([]byte, base64.StdEncoding.EncodedLen(len(cipherText)))  
    
     base64.StdEncoding.Encode(dst, cipherText)  
    
     return dst  
  }  
    
  func (c *crypt) Decrypt(data string) ([]byte, error) {  
     ciphertext := make([]byte, aes.BlockSize+len(data))  
    
     iv := ciphertext[:aes.BlockSize]  
     cfb := cipher.NewCFBDecrypter(c.block, iv)  
    
     cipherText, err := base64.StdEncoding.DecodeString(data)  
     if err != nil {  
        return []byte{}, err  
     }  
    
     plainText := make([]byte, len(cipherText))  
     cfb.XORKeyStream(plainText, cipherText)  
    
     return plainText, nil  
  }
  ```
    - `Encrypt` method will encrypt the data given in the parameter and will return the encrypted string.
    - `Decrypt` method will decrypt the string given in the parameter and will return the decrypted string.
    - `secret` is a constant that has random string that will be used to encrypt and decrypt the data. You are free to update the value of secret but keeping a strong secret is advisable.

### 4. Handler layer
- Add the following code in `handlers/handler.go`:
  ```go
  type Handler interface {  
     Init(ctx *gofr.Context) (interface{}, error)  
     Lock(ctx *gofr.Context) (interface{}, error)  
     Unlock(ctx *gofr.Context) (interface{}, error)  
     Help(_ *gofr.Context) (interface{}, error)  
  }  
    
  type handler struct {  
     service services.FileLocker  
  }  
    
  func New(service services.FileLocker) Handler {  
     return &handler{service: service}  
  }  
    
  func (h *handler) Init(ctx *gofr.Context) (interface{}, error) {  
     password := ctx.Param("password")  
     if password == "" {  
        return nil, errors.New("password is required")  
     }  
    
     err := h.service.Init(password)  
     if err != nil {  
        return nil, err  
     }  
    
     return "file locker initialized", nil  
  }  
    
  func (h *handler) Lock(_ *gofr.Context) (interface{}, error) {  
     err := h.service.Lock()  
     if err != nil {  
        return nil, err  
     }  
    
     return "file locked", nil  
  }  
    
  func (h *handler) Unlock(ctx *gofr.Context) (interface{}, error) {  
     password := ctx.Param("password")  
     if password == "" {  
        return nil, errors.New("password is required")  
     }  
    
     err := h.service.Unlock(password)  
     if err != nil {  
        return nil, err  
     }  
    
     return "file unlocked", nil  
  }  
    
  func (h *handler) Help(_ *gofr.Context) (interface{}, error) {  
     return `File Locker CLI Tool  
    
  Usage:  
   file-locker [command]  
  Available Commands:  
   init      Create a directory named private and initialize the file locker lock      Hide the private directory unlock    Unhides the private directory`, nil  
  }
  ```
    - `handler` is the struct that implements `Handler` interface.
    - `Init` is the handler for `init` command. It takes `password` as the flag. The value for this flag will be used in unlocking.
    - `Lock` method is the handler for `lock` command.
    - `Unlock` method is the handler for `unlock` command. It takes `password` flag for password to unlock.
    - `Help` method is the handler for `help` command. It returns the help string for the app.

### 5. Main
- Add the following code in `main.go`
  ```go
  func main() {  
     var service services.FileLocker  
    
    app := gofr.NewCMD()  
    
     crypt, err := cryptPkg.New()  
     if err != nil {  
        app.Logger().Fatalf("Error occurred: %v", err)  
     }  
    
     switch runtime.GOOS {  
     case constants.Darwin, constants.Linux:  
        service = unix.New(crypt)  
     default:  
        app.Logger().Fatalf("Unsupported architecture: %v", runtime.GOOS)  
     }  
    
     handler := handlers.New(service)  
    
     app.SubCommand("init", handler.Init)  
     app.SubCommand("unlock", handler.Unlock)  
     app.SubCommand("lock", handler.Lock)  
     app.SubCommand("help", handler.Help)  
    
     app.Run()  
  }
  ```
    - `NewCMD()` function creates a command line application
    - Depending upon the architecture of the underlying OS, we are creating service. This service will be injected in the handler. Currently, we have only implemented service layer for Unix based OS hence in the switch statement we only have case for `Darwin` (for MacOS) and `Linux`.
    - `SubCommand` method adds a sub-command to the CLI application. Here, we have added four sub-commands, viz. `init`, `unlock`, `lock`, `help`. The second argument in the `SubCommand` method is the handler that will be called for execution when that particular sub-command is given.
    - `Run()` method will run the application.

Add constants in `constants/constants.go` and our application is ready for use.
```go
const (  
   Darwin = `darwin`  
   Linux = `linux`  
)
```

## Compiling and Having Fun!

-   Compile the program using `go build -o file-locker .`
-   Run the program with the desired action (sub-command):
    ```bash
    ./file-locker <sub-command> [flags]
    ```
    - To create a private directory use:
      ```bash
      ./file-locker init -password=<password>
      ```
      Replace *<password>* with your desired password. This will create a directory named `private` in the current directory. You can put all your files that you require to lock in this directory.
    - To lock (hide) the private directory, use:
      ```bash
      ./file-locker lock
      ```
    - To unlock (unhide) the private directory, use:
      ```bash
      ./file-locker unlock -password=<password>
      ```

## Conclusion
Congratulations! You've built a basic file locker command-line tool in **Go**. It may not be production-ready, but it demonstrates some key concepts of file operations and CLI development using **GoFr**. While we focused on a playful implementation here, this knowledge can be applied to create more robust tools in the future.

Want to see the complete code and experiment further? The entire codebase for this playful file locker is available at [file-locker](https://github.com/ssshekhu53/file-locker). Feel free to clone it, explore the implementation, and make it your own!

**Note:** Currently, this implementation is designed for Unix-based systems. If you're adventurous, try extending it to support Windows by adding platform-specific logic for hiding and unhiding directories.