mirror of
https://github.com/Luzifer/twitch-bot.git
synced 2024-12-20 20:01:17 +00:00
Add fine-grained permission control for extended channel permissions (#35)
This commit is contained in:
parent
4b9878fbf9
commit
a06d8fa1cd
4 changed files with 195 additions and 107 deletions
|
@ -17,10 +17,10 @@ import (
|
|||
|
||||
type (
|
||||
configEditorGeneralConfig struct {
|
||||
BotEditors []string `json:"bot_editors"`
|
||||
BotName *string `json:"bot_name,omitempty"`
|
||||
Channels []string `json:"channels"`
|
||||
ChannelHasScopes map[string]bool `json:"channel_has_scopes"`
|
||||
BotEditors []string `json:"bot_editors"`
|
||||
BotName *string `json:"bot_name,omitempty"`
|
||||
Channels []string `json:"channels"`
|
||||
ChannelScopes map[string][]string `json:"channel_scopes"`
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -135,10 +135,13 @@ func configEditorHandleGeneralAddAuthToken(w http.ResponseWriter, r *http.Reques
|
|||
|
||||
func configEditorHandleGeneralAuthURLs(w http.ResponseWriter, r *http.Request) {
|
||||
var out struct {
|
||||
UpdateBotToken string `json:"update_bot_token"`
|
||||
UpdateChannelScopes string `json:"update_channel_scopes"`
|
||||
AvailableExtendedScopes map[string]string `json:"available_extended_scopes"`
|
||||
UpdateBotToken string `json:"update_bot_token"`
|
||||
UpdateChannelScopes string `json:"update_channel_scopes"`
|
||||
}
|
||||
|
||||
out.AvailableExtendedScopes = channelExtendedScopes
|
||||
|
||||
params := make(url.Values)
|
||||
params.Set("client_id", cfg.TwitchClient)
|
||||
params.Set("redirect_uri", strings.Join([]string{
|
||||
|
@ -155,7 +158,7 @@ func configEditorHandleGeneralAuthURLs(w http.ResponseWriter, r *http.Request) {
|
|||
strings.TrimRight(cfg.BaseURL, "/"),
|
||||
"auth", "update-channel-scopes",
|
||||
}, "/"))
|
||||
params.Set("scope", strings.Join(channelDefaultScopes, " "))
|
||||
params.Set("scope", "")
|
||||
|
||||
out.UpdateChannelScopes = fmt.Sprintf("https://id.twitch.tv/oauth2/authorize?%s", params.Encode())
|
||||
|
||||
|
@ -184,12 +187,12 @@ func configEditorHandleGeneralDeleteAuthToken(w http.ResponseWriter, r *http.Req
|
|||
|
||||
func configEditorHandleGeneralGet(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
elevated = make(map[string]bool)
|
||||
err error
|
||||
channelScopes = make(map[string][]string)
|
||||
err error
|
||||
)
|
||||
|
||||
for _, ch := range config.Channels {
|
||||
if elevated[ch], err = accessService.HasPermissionsForChannel(ch, channelDefaultScopes...); err != nil {
|
||||
if channelScopes[ch], err = accessService.GetChannelPermissions(ch); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
@ -201,10 +204,10 @@ func configEditorHandleGeneralGet(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(configEditorGeneralConfig{
|
||||
BotEditors: config.BotEditors,
|
||||
BotName: uName,
|
||||
Channels: config.Channels,
|
||||
ChannelHasScopes: elevated,
|
||||
BotEditors: config.BotEditors,
|
||||
BotName: uName,
|
||||
Channels: config.Channels,
|
||||
ChannelScopes: channelScopes,
|
||||
}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
|
|
@ -44,6 +44,22 @@ func New(db database.Connector) (*Service, error) {
|
|||
)
|
||||
}
|
||||
|
||||
func (s Service) GetChannelPermissions(channel string) ([]string, error) {
|
||||
var (
|
||||
err error
|
||||
perm extendedPermission
|
||||
)
|
||||
|
||||
if err = s.db.DB().First(&perm, "channel = ?", channel).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(err, "getting twitch credential from database")
|
||||
}
|
||||
|
||||
return strings.Split(perm.Scopes, " "), nil
|
||||
}
|
||||
|
||||
func (s Service) GetBotTwitchClient(cfg ClientConfig) (*twitch.Client, error) {
|
||||
var botAccessToken, botRefreshToken string
|
||||
|
||||
|
@ -98,20 +114,11 @@ func (s Service) GetTwitchClientForChannel(channel string, cfg ClientConfig) (*t
|
|||
}
|
||||
|
||||
func (s Service) HasAnyPermissionForChannel(channel string, scopes ...string) (bool, error) {
|
||||
var (
|
||||
err error
|
||||
perm extendedPermission
|
||||
)
|
||||
|
||||
if err = s.db.DB().First(&perm, "channel = ?", channel).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.Wrap(err, "getting twitch credential from database")
|
||||
storedScopes, err := s.GetChannelPermissions(channel)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "getting channel scopes")
|
||||
}
|
||||
|
||||
storedScopes := strings.Split(perm.Scopes, " ")
|
||||
|
||||
for _, scope := range scopes {
|
||||
if str.StringInSlice(scope, storedScopes) {
|
||||
return true, nil
|
||||
|
@ -122,20 +129,11 @@ func (s Service) HasAnyPermissionForChannel(channel string, scopes ...string) (b
|
|||
}
|
||||
|
||||
func (s Service) HasPermissionsForChannel(channel string, scopes ...string) (bool, error) {
|
||||
var (
|
||||
err error
|
||||
perm extendedPermission
|
||||
)
|
||||
|
||||
if err = s.db.DB().First(&perm, "channel = ?", channel).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.Wrap(err, "getting twitch credential from database")
|
||||
storedScopes, err := s.GetChannelPermissions(channel)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "getting channel scopes")
|
||||
}
|
||||
|
||||
storedScopes := strings.Split(perm.Scopes, " ")
|
||||
|
||||
for _, scope := range scopes {
|
||||
if !str.StringInSlice(scope, storedScopes) {
|
||||
return false, nil
|
||||
|
|
24
scopes.go
24
scopes.go
|
@ -3,20 +3,26 @@ package main
|
|||
import "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
|
||||
|
||||
var (
|
||||
channelDefaultScopes = []string{
|
||||
twitch.ScopeChannelEditCommercial,
|
||||
twitch.ScopeChannelManageBroadcast,
|
||||
twitch.ScopeChannelReadRedemptions,
|
||||
twitch.ScopeChannelManageRaids,
|
||||
channelExtendedScopes = map[string]string{
|
||||
twitch.ScopeChannelEditCommercial: "run commercial",
|
||||
twitch.ScopeChannelManageBroadcast: "modify category / title",
|
||||
twitch.ScopeChannelManagePolls: "manage polls",
|
||||
twitch.ScopeChannelManagePredictions: "manage predictions",
|
||||
twitch.ScopeChannelManageRaids: "start raids",
|
||||
twitch.ScopeChannelManageVIPS: "manage VIPs",
|
||||
twitch.ScopeChannelReadRedemptions: "see channel-point redemptions",
|
||||
}
|
||||
|
||||
botDefaultScopes = append(channelDefaultScopes,
|
||||
twitch.ScopeChatEdit,
|
||||
twitch.ScopeChatRead,
|
||||
botDefaultScopes = []string{
|
||||
// API Scopes
|
||||
twitch.ScopeModeratorManageAnnoucements,
|
||||
twitch.ScopeModeratorManageBannedUsers,
|
||||
twitch.ScopeModeratorManageChatMessages,
|
||||
twitch.ScopeModeratorManageChatSettings,
|
||||
|
||||
// Chat Scopes
|
||||
twitch.ScopeChatEdit,
|
||||
twitch.ScopeChatRead,
|
||||
twitch.ScopeWhisperRead,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
{{ channel }}
|
||||
<span class="ml-auto mr-2">
|
||||
<font-awesome-icon
|
||||
v-if="!generalConfig.channel_has_scopes[channel]"
|
||||
v-if="!hasAllExtendedScopes(channel)"
|
||||
:id="`channelPublicWarn${channel}`"
|
||||
fixed-width
|
||||
class="ml-1 text-warning"
|
||||
|
@ -35,20 +35,30 @@
|
|||
:target="`channelPublicWarn${channel}`"
|
||||
triggers="hover"
|
||||
>
|
||||
Channel cannot use features like channel-point redemptions.
|
||||
See "Channel Permissions" for more info how to authorize.
|
||||
Channel is missing {{ missingExtendedScopes(channel).length }} extended permissions.
|
||||
Click pencil to change granted permissions.
|
||||
</b-tooltip>
|
||||
</span>
|
||||
<b-button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
@click="removeChannel(channel)"
|
||||
>
|
||||
<font-awesome-icon
|
||||
fixed-width
|
||||
:icon="['fas', 'minus']"
|
||||
/>
|
||||
</b-button>
|
||||
<b-button-group size="sm">
|
||||
<b-button
|
||||
variant="primary"
|
||||
@click="editChannelPermissions(channel)"
|
||||
>
|
||||
<font-awesome-icon
|
||||
fixed-width
|
||||
:icon="['fas', 'pencil-alt']"
|
||||
/>
|
||||
</b-button>
|
||||
<b-button
|
||||
variant="danger"
|
||||
@click="removeChannel(channel)"
|
||||
>
|
||||
<font-awesome-icon
|
||||
fixed-width
|
||||
:icon="['fas', 'minus']"
|
||||
/>
|
||||
</b-button>
|
||||
</b-button-group>
|
||||
</b-list-group-item>
|
||||
|
||||
<b-list-group-item>
|
||||
|
@ -281,52 +291,6 @@
|
|||
</b-input-group>
|
||||
</b-card-body>
|
||||
</b-card>
|
||||
|
||||
<b-card
|
||||
no-body
|
||||
class="mb-3"
|
||||
>
|
||||
<b-card-header>
|
||||
<font-awesome-icon
|
||||
fixed-width
|
||||
class="mr-1"
|
||||
:icon="['fas', 'sign-in-alt']"
|
||||
/>
|
||||
Channel Permissions
|
||||
</b-card-header>
|
||||
|
||||
<b-card-body>
|
||||
<p>
|
||||
In order to access non-public information as channel-point redemptions the bot needs additional permissions. The <strong>owner</strong> of the channel needs to grant those!
|
||||
</p>
|
||||
<ul>
|
||||
<li>Copy the URL provided below</li>
|
||||
<li>Pass the URL to the channel owner and tell them to open it with their personal account logged in</li>
|
||||
<li>The bot will display a message containing the updated account</li>
|
||||
</ul>
|
||||
<b-input-group>
|
||||
<b-form-input
|
||||
placeholder="Loading..."
|
||||
readonly
|
||||
:value="authURLs.update_channel_scopes"
|
||||
@focus="$event.target.select()"
|
||||
/>
|
||||
<b-input-group-append>
|
||||
<b-button
|
||||
:variant="copyButtonVariant.channelPermission"
|
||||
@click="copyAuthURL('channelPermission')"
|
||||
>
|
||||
<font-awesome-icon
|
||||
fixed-width
|
||||
class="mr-1"
|
||||
:icon="['fas', 'clipboard']"
|
||||
/>
|
||||
Copy
|
||||
</b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</b-card-body>
|
||||
</b-card>
|
||||
</b-col>
|
||||
</b-row>
|
||||
|
||||
|
@ -366,6 +330,70 @@
|
|||
/>
|
||||
</b-form-group>
|
||||
</b-modal>
|
||||
|
||||
<!-- Channel Permission Editor -->
|
||||
<b-modal
|
||||
v-if="showPermissionEditModal"
|
||||
hide-footer
|
||||
size="lg"
|
||||
title="Edit Permissions for Channel"
|
||||
:visible="showPermissionEditModal"
|
||||
@hidden="showPermissionEditModal=false"
|
||||
>
|
||||
<b-row>
|
||||
<b-col>
|
||||
<p>The bot should be able to…</p>
|
||||
<b-form-checkbox-group
|
||||
id="channelPermissions"
|
||||
v-model="models.channelPermissions"
|
||||
:options="extendedPermissions"
|
||||
multiple
|
||||
:select-size="extendedPermissions.length"
|
||||
stacked
|
||||
switches
|
||||
/>
|
||||
<p class="mt-3">
|
||||
…on this channel.
|
||||
</p>
|
||||
</b-col>
|
||||
<b-col>
|
||||
<p>
|
||||
In order to access non-public information as channel-point redemptions or take actions limited to the channel owner the bot needs additional permissions. The <strong>owner</strong> of the channel needs to grant those!
|
||||
</p>
|
||||
<ul>
|
||||
<li>Select permissions on the left side</li>
|
||||
<li>Copy the URL provided below</li>
|
||||
<li>Pass the URL to the channel owner and tell them to open it with their personal account logged in</li>
|
||||
<li>The bot will display a message containing the updated account</li>
|
||||
</ul>
|
||||
</b-col>
|
||||
</b-row>
|
||||
<b-row>
|
||||
<b-col>
|
||||
<b-input-group>
|
||||
<b-form-input
|
||||
placeholder="Loading..."
|
||||
readonly
|
||||
:value="extendedPermissionsURL"
|
||||
@focus="$event.target.select()"
|
||||
/>
|
||||
<b-input-group-append>
|
||||
<b-button
|
||||
:variant="copyButtonVariant.channelPermission"
|
||||
@click="copyAuthURL('channelPermission')"
|
||||
>
|
||||
<font-awesome-icon
|
||||
fixed-width
|
||||
class="mr-1"
|
||||
:icon="['fas', 'clipboard']"
|
||||
/>
|
||||
Copy
|
||||
</b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -393,6 +421,22 @@ export default {
|
|||
return 'warning'
|
||||
},
|
||||
|
||||
extendedPermissions() {
|
||||
return Object.keys(this.authURLs.available_extended_scopes || {})
|
||||
.map(v => ({ text: this.authURLs.available_extended_scopes[v], value: v }))
|
||||
.sort((a, b) => a.value.localeCompare(b.value))
|
||||
},
|
||||
|
||||
extendedPermissionsURL() {
|
||||
if (!this.authURLs?.update_channel_scopes) {
|
||||
return null
|
||||
}
|
||||
|
||||
const u = new URL(this.authURLs.update_channel_scopes)
|
||||
u.searchParams.set('scope', this.models.channelPermissions.join(' '))
|
||||
return u.toString()
|
||||
},
|
||||
|
||||
sortedChannels() {
|
||||
return [...this.generalConfig?.channels || []].sort((a, b) => a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase()))
|
||||
},
|
||||
|
@ -426,11 +470,13 @@ export default {
|
|||
addChannel: '',
|
||||
addEditor: '',
|
||||
apiToken: {},
|
||||
channelPermissions: [],
|
||||
},
|
||||
|
||||
modules: [],
|
||||
|
||||
showAPITokenEditModal: false,
|
||||
showPermissionEditModal: false,
|
||||
userProfiles: {},
|
||||
}
|
||||
},
|
||||
|
@ -469,7 +515,7 @@ export default {
|
|||
btnField = 'botConnection'
|
||||
break
|
||||
case 'channelPermission':
|
||||
prom = navigator.clipboard.writeText(this.authURLs.update_channel_scopes)
|
||||
prom = navigator.clipboard.writeText(this.extendedPermissionsURL)
|
||||
btnField = 'channelPermission'
|
||||
break
|
||||
}
|
||||
|
@ -488,6 +534,11 @@ export default {
|
|||
})
|
||||
},
|
||||
|
||||
editChannelPermissions(channel) {
|
||||
this.models.channelPermissions = this.generalConfig.channel_scopes[channel] || []
|
||||
this.showPermissionEditModal = true
|
||||
},
|
||||
|
||||
fetchAPITokens() {
|
||||
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, true)
|
||||
return axios.get('config-editor/auth-tokens', this.$root.axiosOptions)
|
||||
|
@ -541,6 +592,36 @@ export default {
|
|||
.catch(err => this.$bus.$emit(constants.NOTIFY_FETCH_ERROR, err))
|
||||
},
|
||||
|
||||
hasAllExtendedScopes(channel) {
|
||||
if (!this.generalConfig.channel_scopes[channel]) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const scope in this.authURLs.available_extended_scopes) {
|
||||
if (!this.generalConfig.channel_scopes[channel].includes(scope)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
missingExtendedScopes(channel) {
|
||||
if (!this.generalConfig.channel_scopes[channel]) {
|
||||
return Object.keys(this.authURLs.available_extended_scopes)
|
||||
}
|
||||
|
||||
const missing = []
|
||||
|
||||
for (const scope in this.authURLs.available_extended_scopes) {
|
||||
if (!this.generalConfig.channel_scopes[channel].includes(scope)) {
|
||||
missing.push(scope)
|
||||
}
|
||||
}
|
||||
|
||||
return missing
|
||||
},
|
||||
|
||||
newAPIToken() {
|
||||
Vue.set(this.models, 'apiToken', {
|
||||
modules: [],
|
||||
|
|
Loading…
Reference in a new issue