update
1. add youtube command 2. add google webhook 3. add twitch oauth route 4. add api checkSession middleware
This commit is contained in:
parent
3318352135
commit
e8db6fdab5
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
||||
config.yml
|
||||
.vscode
|
||||
.idea
|
||||
|
@ -8,7 +8,7 @@ import (
|
||||
type Account struct {
|
||||
ID string `db:"id" cc:"id"`
|
||||
Account string `db:"account" cc:"account"`
|
||||
Password string `db:"password" cc:"password"`
|
||||
Password string `db:"password" cc:"-"`
|
||||
Ctime time.Time `db:"ctime" cc:"ctime"`
|
||||
Mtime time.Time `db:"mtime" cc:"ctime"`
|
||||
}
|
||||
|
@ -1,6 +1,9 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TwitchGroup -
|
||||
type TwitchGroup struct {
|
||||
@ -32,6 +35,24 @@ func GetJoinChatChannel() (channels []*TwitchChannel, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// GetTwitchChannelWithName -
|
||||
func GetTwitchChannelWithName(name string) (ch *TwitchChannel, err error) {
|
||||
if len(name) == 0 {
|
||||
return nil, errors.New("name empty")
|
||||
}
|
||||
err = x.Get(&ch, `select * from "public"."twitch_channel" where "name" = $1`, name)
|
||||
return
|
||||
}
|
||||
|
||||
// GetTwitchChannelWithID -
|
||||
func GetTwitchChannelWithID(id string) (ch *TwitchChannel, err error) {
|
||||
if len(id) == 0 {
|
||||
return nil, errors.New("id empty")
|
||||
}
|
||||
err = x.Get(&ch, `select * from "public"."twitch_channel" where "id" = $1`, id)
|
||||
return
|
||||
}
|
||||
|
||||
// GetWithName -
|
||||
func (p *TwitchChannel) GetWithName() (err error) {
|
||||
stmt, err := x.PrepareNamed(`select * from "public"."twitch_channel" where "name" = :name`)
|
||||
@ -45,7 +66,7 @@ func (p *TwitchChannel) GetWithName() (err error) {
|
||||
|
||||
// Add -
|
||||
func (p *TwitchChannel) Add() (err error) {
|
||||
stmt, err := x.PrepareNamed(`insert into "public"."twitch_channel" ("name", "laststream", "join", "opayid") values (:name, :laststream, :join, :opayid) returning *`)
|
||||
stmt, err := x.PrepareNamed(`insert into "public"."twitch_channel" ("id", "name", "laststream", "join", "opayid") values (:id, :name, :laststream, :join, :opayid) returning *`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -64,6 +85,16 @@ func (p *TwitchChannel) UpdateStream(streamID string) (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// UpdateName -
|
||||
func (p *TwitchChannel) UpdateName(name string) (err error) {
|
||||
_, err = x.Exec(`update "public"."twitch_channel" set "name" = $1 where "id" = $2`, name, p.ID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
p.Name = name
|
||||
return
|
||||
}
|
||||
|
||||
// GetGroups -
|
||||
func (p *TwitchChannel) GetGroups() (err error) {
|
||||
query := `select g.*, rt.tmpl as tmpl from "public"."twitch_channel" tw
|
||||
|
@ -25,6 +25,16 @@ func GetYoutubeChannelWithID(id string) (yt *YoutubeChannel, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// Add -
|
||||
func (p *YoutubeChannel) Add() (err error) {
|
||||
stmt, err := x.PrepareNamed(`insert into "public"."youtube_channel" ("id", "name") values (:id, :name) returning *`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = stmt.Get(p, p)
|
||||
return
|
||||
}
|
||||
|
||||
// UpdateLastVideo -
|
||||
func (p *YoutubeChannel) UpdateLastVideo(vid string) (err error) {
|
||||
p.LastVideo = vid
|
||||
|
@ -37,6 +37,12 @@ var objs = map[string]*ResObject{
|
||||
"message": "input data format error",
|
||||
},
|
||||
},
|
||||
"LoginFirst": &ResObject{
|
||||
Status: 401,
|
||||
Obj: map[string]string{
|
||||
"message": "login first",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// GetRes -
|
||||
|
157
module/apis/google/google.go
Normal file
157
module/apis/google/google.go
Normal file
@ -0,0 +1,157 @@
|
||||
package google
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"git.trj.tw/golang/mtfosbot/module/apis"
|
||||
|
||||
"git.trj.tw/golang/mtfosbot/module/config"
|
||||
)
|
||||
|
||||
var baseURL = "https://www.googleapis.com"
|
||||
|
||||
func getURL(p string, querystring ...interface{}) (string, bool) {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
ref, err := u.Parse(p)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if len(querystring) > 0 {
|
||||
switch querystring[0].(type) {
|
||||
case string:
|
||||
ref, err = ref.Parse(fmt.Sprintf("?%s", (querystring[0].(string))))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
break
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
str := ref.String()
|
||||
return str, true
|
||||
}
|
||||
|
||||
func getHeaders(token ...interface{}) map[string]string {
|
||||
m := make(map[string]string)
|
||||
m["Content-Type"] = "application/json"
|
||||
return m
|
||||
}
|
||||
|
||||
type channelItem struct {
|
||||
ID string `json:"id"`
|
||||
Sinppet channelSinppet `json:"sinppet"`
|
||||
}
|
||||
type channelSinppet struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
CustomURL string `json:"customUrl"`
|
||||
}
|
||||
|
||||
// QueryYoutubeName -
|
||||
func QueryYoutubeName(id string) (n string, err error) {
|
||||
conf := config.GetConf()
|
||||
if len(id) == 0 {
|
||||
return "", errors.New("id is empty")
|
||||
}
|
||||
qs := url.Values{}
|
||||
qs.Add("id", id)
|
||||
qs.Add("key", conf.Google.APIKey)
|
||||
qs.Add("part", "snippet")
|
||||
|
||||
apiURL, ok := getURL("/youtube/v3/channels", qs.Encode())
|
||||
if !ok {
|
||||
return "", errors.New("url parser fail")
|
||||
}
|
||||
reqObj := apis.RequestObj{
|
||||
Method: "GET",
|
||||
URL: apiURL,
|
||||
Headers: getHeaders(),
|
||||
}
|
||||
|
||||
req, err := apis.GetRequest(reqObj)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 || !strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") {
|
||||
return "", errors.New("api response fail")
|
||||
}
|
||||
|
||||
bodyBytes, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
apiRes := struct {
|
||||
Items []channelItem `json:"items"`
|
||||
}{}
|
||||
|
||||
err = json.Unmarshal(bodyBytes, &apiRes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(apiRes.Items) == 0 {
|
||||
return "", errors.New("channel data not found")
|
||||
}
|
||||
|
||||
for _, v := range apiRes.Items {
|
||||
if v.ID == id {
|
||||
return v.Sinppet.Title, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New("channel data not found")
|
||||
}
|
||||
|
||||
// SubscribeYoutube -
|
||||
func SubscribeYoutube(id string) {
|
||||
if len(id) == 0 {
|
||||
return
|
||||
}
|
||||
conf := config.GetConf()
|
||||
apiURL := "https://pubsubhubbub.appspot.com/subscribe"
|
||||
cbURL, err := url.Parse(conf.URL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cbURL, err = cbURL.Parse(fmt.Sprintf("/google/youtube/webhook?id=%s", id))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
qs := url.Values{}
|
||||
qs.Add("hub.mode", "subscribe")
|
||||
qs.Add("hub.verify", "async")
|
||||
qs.Add("hub.topic", fmt.Sprintf("https://www.youtube.com/xml/feeds/videos.xml?channel_id=%s", id))
|
||||
qs.Add("hub.callback", cbURL.String())
|
||||
qs.Add("hub.lease_seconds", "86400")
|
||||
|
||||
req, err := http.NewRequest("POST", apiURL, strings.NewReader(qs.Encode()))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return
|
||||
}
|
@ -2,6 +2,7 @@ package twitch
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
@ -234,3 +235,66 @@ func GetUserStreamStatus(ids []string) (info []*StreamInfo) {
|
||||
|
||||
return apiData.Data
|
||||
}
|
||||
|
||||
// TwitchTokenData -
|
||||
type TwitchTokenData struct {
|
||||
AccessToken string `json:"access_token" cc:"access_token"`
|
||||
RefreshToken string `json:"refresh_token" cc:"refresh_token"`
|
||||
ExpiresIn int64 `json:"expires_in" cc:"expires_in"`
|
||||
Scope string `json:"scope" cc:"scope"`
|
||||
TokenType string `json:"token_type" cc:"token_type"`
|
||||
}
|
||||
|
||||
// GetTokenData -
|
||||
func GetTokenData(code string) (token *TwitchTokenData, err error) {
|
||||
if len(code) == 0 {
|
||||
return nil, errors.New("code is empty")
|
||||
}
|
||||
conf := config.GetConf()
|
||||
twitchURL := "https://id.twitch.tv/oauth2/token"
|
||||
redirectTo := strings.TrimRight(conf.URL, "/") + "/twitch/oauth"
|
||||
|
||||
qs := url.Values{}
|
||||
qs.Add("client_id", conf.Twitch.ClientID)
|
||||
qs.Add("client_secret", conf.Twitch.ClientSecret)
|
||||
qs.Add("code", code)
|
||||
qs.Add("grant_type", "authorization_code")
|
||||
qs.Add("redirect_uri", redirectTo)
|
||||
|
||||
u, err := url.Parse(twitchURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u, err = u.Parse(qs.Encode())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reqObj := apis.RequestObj{
|
||||
URL: u.String(),
|
||||
Method: "POST",
|
||||
}
|
||||
req, err := apis.GetRequest(reqObj)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 || strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") {
|
||||
return nil, errors.New("api response error")
|
||||
}
|
||||
|
||||
bodyBytes, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(bodyBytes, &token)
|
||||
|
||||
return
|
||||
}
|
||||
|
@ -35,6 +35,12 @@ func (c *Context) CustomRes(status int, msg interface{}) {
|
||||
c.AbortWithStatusJSON(status, msg)
|
||||
}
|
||||
|
||||
// LoginFirst -
|
||||
func (c *Context) LoginFirst(msg interface{}) {
|
||||
obj := apimsg.GetRes("LoginFirst", msg)
|
||||
c.AbortWithStatusJSON(obj.Status, obj.Obj)
|
||||
}
|
||||
|
||||
// NotFound -
|
||||
func (c *Context) NotFound(msg interface{}) {
|
||||
obj := apimsg.GetRes("NotFound", msg)
|
||||
|
@ -7,6 +7,7 @@ import (
|
||||
"git.trj.tw/golang/mtfosbot/module/apis/twitch"
|
||||
|
||||
"git.trj.tw/golang/mtfosbot/model"
|
||||
googleapi "git.trj.tw/golang/mtfosbot/module/apis/google"
|
||||
lineobj "git.trj.tw/golang/mtfosbot/module/line-message/line-object"
|
||||
)
|
||||
|
||||
@ -173,7 +174,12 @@ func addTwitchChannel(sub, txt string, s *lineobj.SourceObject) (res string) {
|
||||
return "get twitch user id fail"
|
||||
}
|
||||
|
||||
ch := &model.TwitchChannel{
|
||||
ch, err := model.GetTwitchChannelWithName(args[0])
|
||||
if err != nil {
|
||||
return "check channel fail"
|
||||
}
|
||||
if ch == nil {
|
||||
ch = &model.TwitchChannel{
|
||||
ID: info.ID,
|
||||
Name: info.DisplayName,
|
||||
}
|
||||
@ -181,6 +187,7 @@ func addTwitchChannel(sub, txt string, s *lineobj.SourceObject) (res string) {
|
||||
if err != nil {
|
||||
return "add twitch channel fail"
|
||||
}
|
||||
}
|
||||
|
||||
rt := &model.LineTwitchRT{
|
||||
Line: s.GroupID,
|
||||
@ -239,3 +246,50 @@ func delTwitchChannel(sub, txt string, s *lineobj.SourceObject) (res string) {
|
||||
|
||||
return "Success"
|
||||
}
|
||||
|
||||
func addYoutubeChannel(sub, txt string, s *lineobj.SourceObject) (res string) {
|
||||
// args = youtubeID tmpl
|
||||
ok, err := checkGroupOwner(s)
|
||||
if err != nil {
|
||||
return "check group fail"
|
||||
}
|
||||
if !ok {
|
||||
return "not owner"
|
||||
}
|
||||
|
||||
args := strings.Split(strings.Trim(txt, " "), " ")
|
||||
if len(args) < 2 {
|
||||
return "command arg not match"
|
||||
}
|
||||
ytName, err := googleapi.QueryYoutubeName(args[0])
|
||||
if err != nil || len(ytName) == 0 {
|
||||
return "get youtube channel name fail"
|
||||
}
|
||||
|
||||
ytData, err := model.GetYoutubeChannelWithID(args[0])
|
||||
if err != nil {
|
||||
return "check youtube fail"
|
||||
}
|
||||
if ytData == nil {
|
||||
ytData = &model.YoutubeChannel{
|
||||
ID: args[0],
|
||||
Name: ytName,
|
||||
}
|
||||
err = ytData.Add()
|
||||
if err != nil {
|
||||
return "add youtube channel fail"
|
||||
}
|
||||
}
|
||||
|
||||
rt := &model.LineYoutubeRT{
|
||||
Line: s.GroupID,
|
||||
Youtube: args[0],
|
||||
Tmpl: strings.Join(args[1:], " "),
|
||||
}
|
||||
err = rt.AddRT()
|
||||
if err != nil {
|
||||
return "add youtube channel rt fail"
|
||||
}
|
||||
|
||||
return "Success"
|
||||
}
|
||||
|
@ -2,12 +2,47 @@ package api
|
||||
|
||||
import (
|
||||
"git.trj.tw/golang/mtfosbot/model"
|
||||
"git.trj.tw/golang/mtfosbot/module/apis/twitch"
|
||||
"git.trj.tw/golang/mtfosbot/module/context"
|
||||
"git.trj.tw/golang/mtfosbot/module/utils"
|
||||
"github.com/gin-gonic/contrib/sessions"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// CheckSession -
|
||||
func CheckSession(c *context.Context) {
|
||||
session := sessions.Default(c.Context)
|
||||
userData := session.Get("user")
|
||||
loginType := session.Get("loginType")
|
||||
if userData == nil || loginType == nil {
|
||||
c.LoginFirst(nil)
|
||||
return
|
||||
}
|
||||
var name string
|
||||
var ltype string
|
||||
var ok bool
|
||||
switch userData.(type) {
|
||||
case model.Account:
|
||||
name = userData.(model.Account).Account
|
||||
case twitch.UserInfo:
|
||||
name = userData.(twitch.UserInfo).DisplayName
|
||||
default:
|
||||
c.LoginFirst(nil)
|
||||
return
|
||||
}
|
||||
if ltype, ok = loginType.(string); !ok {
|
||||
c.LoginFirst(nil)
|
||||
return
|
||||
}
|
||||
|
||||
loginUser := map[string]string{
|
||||
"name": name,
|
||||
"type": ltype,
|
||||
}
|
||||
session.Set("loginUser", loginUser)
|
||||
session.Save()
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// UserLogin - system user login
|
||||
func UserLogin(c *context.Context) {
|
||||
bodyArg := struct {
|
||||
@ -32,11 +67,10 @@ func UserLogin(c *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
accInt := utils.ToMap(acc)
|
||||
delete(accInt, "password")
|
||||
session := sessions.Default(c.Context)
|
||||
|
||||
session.Set("user", accInt)
|
||||
acc.Password = ""
|
||||
session.Set("user", acc)
|
||||
session.Set("loginType", "system")
|
||||
session.Save()
|
||||
|
||||
|
@ -7,6 +7,7 @@ import (
|
||||
"git.trj.tw/golang/mtfosbot/router/api"
|
||||
"git.trj.tw/golang/mtfosbot/router/google"
|
||||
"git.trj.tw/golang/mtfosbot/router/line"
|
||||
"git.trj.tw/golang/mtfosbot/router/twitch"
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
@ -44,6 +45,7 @@ func SetRoutes(r *gin.Engine) {
|
||||
apiGroup := r.Group("/api")
|
||||
{
|
||||
apiGroup.POST("/login", context.PatchCtx(api.UserLogin))
|
||||
apiGroup.POST("/logout", context.PatchCtx(api.UserLogout))
|
||||
}
|
||||
|
||||
lineApis := r.Group("/line")
|
||||
@ -56,4 +58,10 @@ func SetRoutes(r *gin.Engine) {
|
||||
googleApis.GET("/youtube/webhook", context.PatchCtx(google.VerifyWebhook))
|
||||
googleApis.POST("/youtube/webhook", context.PatchCtx(google.GetNotifyWebhook))
|
||||
}
|
||||
|
||||
twitchApis := r.Group("/twitch")
|
||||
{
|
||||
twitchApis.GET("/login", context.PatchCtx(twitch.OAuthLogin))
|
||||
twitchApis.GET("/oauth", context.PatchCtx(twitch.OAuthProc))
|
||||
}
|
||||
}
|
||||
|
103
router/twitch/twitch.go
Normal file
103
router/twitch/twitch.go
Normal file
@ -0,0 +1,103 @@
|
||||
package twitch
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"git.trj.tw/golang/mtfosbot/model"
|
||||
twitchapi "git.trj.tw/golang/mtfosbot/module/apis/twitch"
|
||||
"git.trj.tw/golang/mtfosbot/module/config"
|
||||
"git.trj.tw/golang/mtfosbot/module/context"
|
||||
"github.com/gin-gonic/contrib/sessions"
|
||||
)
|
||||
|
||||
// OAuthLogin -
|
||||
func OAuthLogin(c *context.Context) {
|
||||
session := sessions.Default(c.Context)
|
||||
conf := config.GetConf()
|
||||
redirectTo := strings.TrimRight(conf.URL, "/")
|
||||
redirectTo += "/twitch/oauth"
|
||||
qs := url.Values{}
|
||||
qs.Add("client_id", conf.Twitch.ClientID)
|
||||
qs.Add("redirect_uri", redirectTo)
|
||||
qs.Add("response_type", "code")
|
||||
qs.Add("scope", "user:read:email")
|
||||
|
||||
toURL, ok := c.GetQuery("tourl")
|
||||
if ok && len(toURL) > 0 {
|
||||
session.Set("backUrl", toURL)
|
||||
session.Save()
|
||||
}
|
||||
|
||||
u, err := url.Parse(redirectTo)
|
||||
if err != nil {
|
||||
c.ServerError(nil)
|
||||
return
|
||||
}
|
||||
finalURL, err := u.Parse(qs.Encode())
|
||||
if err != nil {
|
||||
c.ServerError(nil)
|
||||
return
|
||||
}
|
||||
c.Redirect(301, finalURL.String())
|
||||
}
|
||||
|
||||
// OAuthProc -
|
||||
func OAuthProc(c *context.Context) {
|
||||
code, ok := c.GetQuery("code")
|
||||
if !ok || len(code) == 0 {
|
||||
c.DataFormat(nil)
|
||||
return
|
||||
}
|
||||
|
||||
tokenData, err := twitchapi.GetTokenData(code)
|
||||
if err != nil {
|
||||
c.DataFormat(nil)
|
||||
return
|
||||
}
|
||||
|
||||
session := sessions.Default(c.Context)
|
||||
|
||||
userData := twitchapi.GetUserDataByToken(tokenData.AccessToken)
|
||||
if userData == nil {
|
||||
c.ServerError(nil)
|
||||
return
|
||||
}
|
||||
|
||||
session.Set("token", tokenData)
|
||||
session.Set("user", userData)
|
||||
session.Set("loginType", "twitch")
|
||||
|
||||
chData, err := model.GetTwitchChannelWithID(userData.ID)
|
||||
if err != nil {
|
||||
c.ServerError(nil)
|
||||
return
|
||||
}
|
||||
if chData == nil {
|
||||
chData = &model.TwitchChannel{
|
||||
ID: userData.ID,
|
||||
Name: userData.DisplayName,
|
||||
}
|
||||
err = chData.Add()
|
||||
if err != nil {
|
||||
c.ServerError(nil)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if userData.DisplayName != chData.Name {
|
||||
chData.UpdateName(userData.DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
conf := config.GetConf()
|
||||
goURL := strings.TrimRight(conf.URL, "/") + "/web"
|
||||
tourl := session.Get("backUrl")
|
||||
if tourl != nil {
|
||||
if str, ok := tourl.(string); ok {
|
||||
goURL = str
|
||||
session.Delete("backUrl")
|
||||
}
|
||||
}
|
||||
session.Save()
|
||||
c.Redirect(301, goURL)
|
||||
}
|
Loading…
Reference in New Issue
Block a user