🤖

招待コードを知っているユーザのみYoutubeURLを取得できるAPIをGoで実装してみた

2022/12/08に公開

こんにちは
今年の冬は全然乗り切れそうだと感じて無駄に冬服を買わずに済みますね(家から出ないだけ)
今回の記事は途中までノリでサービス化しようと思ったけど、ペルソナを改めて考えたらそこまでだったので、
パブリックにすることにしたプロジェクトのお話
今回はGoで実装しています。というか最近はずっとGoを書いています。そろそろ新しい言語に手を出すか迷っていますが、そこまでモチベーションん高くないので多分Goを使い続けるはず

概要

https://github.com/lll-lll-lll-lll/invitation-youtubeurl-go

途中まで作ろうとしていたプロジェクトの概要はこんな感じ

  • IDとパスワードとYoutubeURLを設定すると招待コードが生成される
  • 招待コードとIDとパスワードが正しいとYoutubeURLを取得できる
    アプリの内容自体は超シンプルです

じゃあなぜこんなものを作ろうとしたか
そこまで頻度は高くないんですが料理をすることがあるんです。紙の本を購入しページをめくっていると、レシピの最後の方に「Youtubeにも動画を載せています」的なことが書かれていてたりするんです。
で、個人的には「QRコード載せてくれよ」とか思うわけですが、それは普段から情報系に関わっている人間の発想で、そうでない人からすると動画を限定公開に設定し、URLからQRコードを生成するのって意外と手間なのかなとか思ったわけです。そもそもそんな発想しないのかなと。なので、IDとパスワードとYoutubeURL設定したら招待コードを生成できるアプリあるといいなっていうのが作ろうと思ったきっかけです。

技術スタック

  • Go
  • Gin
  • Firebase
  • PostgreSQL

招待コードからYoutubeURLを取得するまで

招待コードからYoutubeURLを取得するまでの流れをまずは一覧化します
今回は認証にFirebaseを挟んでいます。

  1. ユーザを作成する
  2. 認証を挟み、ユーザがidとパスワード、YoutubeURLを指定し、招待コードを生成する
  3. 招待コードを知った他のユーザが招待コードとIDとパスワードを入力し正しいとYoutubeURLを取得する

ざっとこんな流れ
もう少し細かく説明します

1.

1.ではFirebaseでユーザ作成する際に今回はユーザ名とメールアドレスで作成します。

func RegisterHandler(firebaseApp *fb.FirebaseApp, db *sqlx.DB) gin.HandlerFunc {
	return func(ctx *gin.Context) {
		var input fb.RegisterUserBody
		if err := ctx.BindJSON(&input); err != nil {
			ctx.JSON(http.StatusBadRequest, gin.H{"message": "failed to bind json"})
			return
		}
		// firebaseのuidとdbのuidを統一させるためサーバ側で生成
		userID := xid.New().String()
		req := &fb.RegisterUser{ID: userID, Email: input.Email, Password: input.Password, Name: input.Name}
		// firebaseにユーザ登録
		record, err := fb.CreateUserWithUID(ctx, firebaseApp.Client, req)
		if err != nil {
			ctx.JSON(http.StatusBadRequest, gin.H{"message": fmt.Sprintf("ユーザ作成に失敗しました")})
			return
		}
		//dbにインサート
		if err := repository.InsertUser(req, db); err != nil {
			ctx.JSON(http.StatusBadRequest, gin.H{"message": fmt.Sprintf("ユーザ作成失敗. %s", err.Error())})
			return
		}
		ctx.JSON(http.StatusCreated, gin.H{"message": record.UID})
	}
}

ユーザを作成する際にfirebaseとdbとのユーザidを共通にするために、サービス側で生成したuidを使用

2.

2.ではまず先ほど作成したユーザでfirebaseで認証を挟み、Firebase ID トークン
を取得する。そのトークンを使ってミドルウェアで認可をする。
次にIDとパスワード、YoutubeURLを指定して招待コードを生成する

招待コードの生成とDB保存までの流れは以下

  1. リクエストのボディをInput構造体にバインドする。Stringメソッドは暗号化対象の平文とする。
  2. Newメソッドで招待コードの生成とaesで平文となるIDとパスワードを繋げた文字列を暗号化する、最後にContainer構造体に落とし込む。Containerという単位でURLや招待コードなどをまとめています。IVとKeyも復号の際に使用するのでDBに保存するためContainer構造体に落とし込んでいます。また招待コードはランダムな文字列でかつテーブルでユニークであれば良いので、invitation_codesテーブルを用意しそのPKとして設定(GenerateRandomCodeメソッドで生成)
type Input struct {
	ID       string `json:"id"`
	Password string `json:"password"`
	URL      string `json:"youtube_url"`
}

func (i Input) String() string {
	return fmt.Sprintf("%s.%s", i.ID, i.Password)
}
func New(input Input) (*Container, error) {
	byteNum := 32
	plaintext := input.String()
	rawurl := input.URL
	code, err := inv.GenerateRandomCode()
	// youtubeのURLかどうかチェック
	if err := config.ToYouTubeURL(rawurl).Validate(); err != nil {
		return nil, err
	}
	//暗号化の際に使用するkeyとivを生成
	key, iv, err := aes.GenerateKeyAndIV(uint32(byteNum))
	if err != nil {
		return nil, err
	}
	cipher, err := aes.NewAES(key)
	if err != nil {
		return nil, err
	}
	encryptedText := aes.Encrypt(cipher, iv, plaintext)
	container := &Container{
		IV:            iv,
		Key:           key,
		EncryptedText: encryptedText,
		Code:          code,
		YoutubeURL:    input.URL,
	}
	return container, nil
}
type Container struct {
	// 復号に使うIV
	IV IVType `json:"iv"`
	// 復号に使うkey
	Key string `json:"key"`
	// idとパスワードを含んだ暗号文
	EncryptedText EncryptedTextType `json:"encrypted_text"`
	// 招待コード
	Code string `json:"code"`
	// youtube url
	YoutubeURL string `json:"youtube_url"`
}

//招待コード生成するメソッド
func GenerateRandomCode() (string, error) {
	length := 6
	b := make([]byte, length)
	_, err := rand.Read(b)
	if err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(b), nil
}

3.

3.ではリクエストからIDとパスワードと招待コードをバインドします
リクエストの招待コードからid, パスワード,key, iv をdbから取得し、key,iv,idとパスワードを繋げた文字列からaesで復号します。その復号がうまくいった場合YoutubeURLを返す流れです

type GetInvitationInput struct {
	Code     string `json:"code"`
	ID       string `json:"id"`
	Password string `json:"password"`
}


type InvitationBody struct {
	UserID        string `db:"id" json:"id"`
	Code          string `db:"invitation_code" json:"invitation_code"`
	IV            string `db:"iv" json:"iv"`
	Key           string `db:"key" json:"key"`
	EncryptedText string `db:"encrypted_text" json:"encrypted_text"`
	YoutubeURL    string `db:"url" json:"youtube_url"`
}


func (ib GetInvitationInput) String() string {
	return fmt.Sprintf("%s.%s", ib.ID, ib.Password)
}


func GetYoutubeURLByInvitationCode(db *sqlx.DB) gin.HandlerFunc {
	return func(ctx *gin.Context) {
		var input GetInvitationInput
		if err := ctx.BindJSON(&input); err != nil {
			ctx.JSON(http.StatusBadRequest, gin.H{"message": "faild to bind json"})
			return
		}
		var invitationBody InvitationBody
		if err := db.QueryRowx("SELECT * FROM invitation WHERE invitation_code = $1", input.Code).StructScan(&invitationBody); err != nil {
			if err == sql.ErrNoRows {
				ctx.JSON(http.StatusBadRequest, fmt.Errorf("code is %v: unknown", input.Code))
				return
			}
			ctx.JSON(http.StatusBadRequest, fmt.Errorf("error is %s", err.Error()))
			return
		}
		cipher, err := aes.NewAES(invitationBody.Key)
		if err != nil {
			ctx.JSON(http.StatusBadRequest, gin.H{"message": "AESの初期化に失敗"})
			return
		}
		decodedIV, err := hex.DecodeString(invitationBody.IV)
		if err != nil {
			ctx.JSON(http.StatusBadRequest, gin.H{"message": fmt.Sprintf("IVデコードの失敗")})
			return
		}
		decodedET, err := hex.DecodeString(invitationBody.EncryptedText)
		if err != nil {
			ctx.JSON(http.StatusBadRequest, gin.H{"message": "Encrypted Textデコードの失敗"})
			return
		}
		// IDとパスワードが正しいか
		_, err = crypto.Decrypt(cipher, decodedIV, input.String(), decodedET)
		if err != nil {
			ctx.JSON(http.StatusBadRequest, gin.H{"message": fmt.Sprintf("faild to decrypt. check whether password or id is wrong error is %s", err.Error())})
			return
		}
		ctx.JSON(http.StatusOK, gin.H{"message": invitationBody.YoutubeURL})
	}
}

テーブル

テーブル設計は最小限にしています

CREATE TABLE users (
    id VARCHAR(100) PRIMARY KEY NOT NULL,
    name VARCHAR(255) NOT NULL
);

CREATE TABLE invitation (
    id VARCHAR(100) PRIMARY KEY NOT NULL references users(id),
    invitation_code VARCHAR(100) NOT NULL,
    iv TEXT NOT NULL,
    key TEXT NOT NULL,
    encrypted_text TEXT NOT NULL,
    url TEXT NOT NULL,
    CONSTRAINT FK_Invitation_Code FOREIGN KEY (invitation_code) REFERENCES invitation_codes(code)
);

CREATE TABLE invitation_codes (code VARCHAR(100) PRIMARY KEY);

dbインサート時のTransaction

DBへのインサート時特別な処理がない場合は以下のトランザクションメソッドを使用しています
この書き方の好きなところはreq引数がinterfaceのおかげで引数の構造体を自由に選ぶことができ、sqlを自由に書くことが出来つつ、トランザクションメソッドの中身を複数回書かなくて済む点
逆に返り値を取得したいときには別の書き方が必要なので、複雑なsqlを書くなら使わないかもしれませんが。

func Transaction(db *sqlx.DB, req interface{}, f func(req interface{}, db *sqlx.DB) error) error {
	tx, err := db.Begin()
	if err != nil {
		return err
	}
	defer func() error {
		if err := recover(); err != nil {
			if err := tx.Rollback(); err != nil {
				return err
			}
		}
		return nil
	}()

	if err := f(req, db); err != nil {
		return err
	}

	if err := tx.Commit(); err != nil {
		return err
	}
	return nil
}

func InsertInvitationCode(con *container.Container, db *sqlx.DB) error {
	postCode := PostCode{Code: con.Code}
	if err := Transaction(db, postCode, insertCodeFunc); err != nil {
		return err
	}
	return nil
}

type PostCode struct {
	Code string `json:"code"`
}

func insertCodeFunc(req interface{}, db *sqlx.DB) error {
	castedReq := req.(PostCode)
	stmt, err := db.Prepare("INSERT INTO invitation_codes(code) VALUES($1)")
	if err != nil {
		return err
	}
	_, err = stmt.Exec(castedReq.Code)
	if err != nil {
		return err
	}
	

まとめ

途中でサービス化することを諦めたとはいえ学びになることが多く次の開発にも活きる経験ができたと思います。
コードは公開しているので少しでも誰かのためになれば幸いです

Discussion