Basic database structure and partial implementation for UACs and tokens

This commit is contained in:
digimint 2024-06-24 03:41:00 -05:00
parent 9c6a1e808f
commit 36afeaad42
Signed by: digimint
GPG key ID: 947E0031C671B9A0
13 changed files with 454 additions and 192 deletions

View file

@ -1,30 +1,37 @@
/* /*
UNIT_CA5 - Stream management bot UNIT_CA5 - Stream management bot
Copyright (C) 2024 digimint Copyright (C) 2024 digimint
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details. GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package unit_ca5
import slick.jdbc.PostgresProfile.api._ import db.DatabaseLayer
import twitch.api.UserAuthenticationCredential
import slick.jdbc.PostgresProfile
import slick.jdbc.H2Profile
import scala.concurrent.Await import scala.concurrent.Await
import scala.concurrent.duration.Duration import scala.concurrent.duration.Duration
import com.typesafe.config.ConfigFactory import com.typesafe.config.ConfigFactory
import com.typesafe.config.Config import com.typesafe.config.Config
import java.time.Instant import java.time.Instant
import twitch.api.TokenScope
@main def hello(): Unit = import scala.concurrent.ExecutionContext.Implicits.global
@main def main(): Unit =
println("Hello world!") println("Hello world!")
println(msg) println(msg)
@ -33,71 +40,107 @@ import java.time.Instant
println(s"Test config key: ${test_key}") println(s"Test config key: ${test_key}")
Await.result(DBConnection.doSetup, Duration.Inf) println("Initializing database")
println("Database initialized") val dbLayer = Await.result(
DatabaseLayer.setup(
PostgresProfile,
"db_conf"
),
Duration.Inf
)
Await.result(DBConnection.doTestInserts, Duration.Inf) println("Performing some test inserts...")
println("Test values inserted")
val query = dbLayer.uac.api.create(
UserAuthenticationCredential(
"digimint",
"asdfasdfasdfasdf",
"asdfasdfasdfasdf",
Instant.MAX,
List(
TokenScope.AnalyticsReadExtensions,
TokenScope.BitsRead,
TokenScope.ChannelManagePolls
)
)
)
Await.result(dbLayer.exec.execute(query), Duration.Inf)
println("Querying result...")
val q2 = dbLayer.uac.api.retrieve("digimint")
val res = Await.result(dbLayer.exec.execute(q2), Duration.Inf)
println(res)
// Await.result(DBConnection.doSetup, Duration.Inf)
// println("Database initialized")
// Await.result(DBConnection.doTestInserts, Duration.Inf)
// println("Test values inserted")
def msg = "I was compiled by Scala 3. :)" def msg = "I was compiled by Scala 3. :)"
class UserTokens(tag: Tag) extends Table[(Int, String, String, String, Instant, Int)](tag, "USER_TOKENS"): // class UserTokens(tag: Tag) extends Table[(Int, String, String, String, Instant, Int)](tag, "USER_TOKENS"):
def id = column[Int]("TKN_ID", O.PrimaryKey, O.AutoInc) // def id = column[Int]("TKN_ID", O.PrimaryKey, O.AutoInc)
def userId = column[String]("USER_ID") // def userId = column[String]("USER_ID")
def accessToken = column[String]("ACCESS_TKN") // def accessToken = column[String]("ACCESS_TKN")
def refreshToken = column[String]("REFRESH_TKN") // def refreshToken = column[String]("REFRESH_TKN")
def expires = column[Instant]("EXPIRES") // def expires = column[Instant]("EXPIRES")
// Compact representation of the scopes granted to this token. If a version // // Compact representation of the scopes granted to this token. If a version
// update changes the set of required scopes, the "current" SCOPES_VERSION // // update changes the set of required scopes, the "current" SCOPES_VERSION
// is incremented. This then gets mapped to a set of actual scopes, which // // is incremented. This then gets mapped to a set of actual scopes, which
// other functions can check. // // other functions can check.
def scopes_version = column[Int]("SCOPES_VERSION") // def scopes_version = column[Int]("SCOPES_VERSION")
def * = (id, userId, accessToken, refreshToken, expires, scopes_version) // def * = (id, userId, accessToken, refreshToken, expires, scopes_version)
def user = foreignKey("USER_FK", userId, authUsers)(_.userId) // def user = foreignKey("USER_FK", userId, authUsers)(_.userId)
val userTokens = TableQuery[UserTokens] // val userTokens = TableQuery[UserTokens]
class AuthUsers(tag: Tag) extends Table[(String, String)](tag, "USERS"): // class AuthUsers(tag: Tag) extends Table[(String, String)](tag, "USERS"):
def userId = column[String]("USER_ID", O.PrimaryKey) // def userId = column[String]("USER_ID", O.PrimaryKey)
def name = column[String]("TWITCH_USERNAME") // def name = column[String]("TWITCH_USERNAME")
def * = (userId, name) // def * = (userId, name)
val authUsers = TableQuery[AuthUsers] // val authUsers = TableQuery[AuthUsers]
object DBConnection{ // object DBConnection{
val db = Database.forConfig("db_conf") // val db = Database.forConfig("db_conf")
val setup = DBIO.seq( // val setup = DBIO.seq(
(authUsers.schema ++ userTokens.schema).createIfNotExists // (authUsers.schema ++ userTokens.schema).createIfNotExists
) // )
def doSetup = // def doSetup =
db.run(setup) // db.run(setup)
def doTestInserts = // def doTestInserts =
val testInserts = DBIO.seq( // val testInserts = DBIO.seq(
authUsers ++= Seq( // authUsers ++= Seq(
("a", "digimint"), // ("a", "digimint"),
("b", "PancakeDragoness") // ("b", "PancakeDragoness")
), // ),
userTokens ++= Seq( // userTokens ++= Seq(
(1, "a", "coolAccessToken", "coolRefreshToken", Instant.now, 1), // (1, "a", "coolAccessToken", "coolRefreshToken", Instant.now, 1),
(2, "b", "hotAccessToken", "hotRefreshToken", Instant.now, 1) // (2, "b", "hotAccessToken", "hotRefreshToken", Instant.now, 1)
) // )
) // )
db.run(testInserts) // db.run(testInserts)
// val userInserts: DBIO[Option[Int]] = authUsers ++= Seq ( // // val userInserts: DBIO[Option[Int]] = authUsers ++= Seq (
// ("a", "digimint"), // // ("a", "digimint"),
// ("b", "PancakeDragoness") // // ("b", "PancakeDragoness")
// ) // // )
// val tokensInserts: DBIO[Option[Int]] = userTokens ++= Seq ( // // val tokensInserts: DBIO[Option[Int]] = userTokens ++= Seq (
// () // // ()
// ) // // )
} // }

View file

@ -0,0 +1,41 @@
package db
import modules.uac.UACModule
import modules.executor.ExecutorModule
import scala.concurrent.Future
import slick.jdbc.JdbcProfile
import scala.concurrent.ExecutionContext
class DatabaseLayer(
val uac: UACModule,
val exec: ExecutorModule
)
object DatabaseLayer:
def setup(
profile: JdbcProfile,
config_loc: String = "db_conf"
)(implicit ec: ExecutionContext): Future[DatabaseLayer] =
import profile.api._
val database = Database.forConfig(config_loc)
// Construct and initialize modules
val uacModule = UACModule(profile, database)
val uacSetupActions = uacModule.setup
val executorModule = ExecutorModule(profile, database)
val executorSetupOptions = executorModule.setup
// Run initialization actions
val initActions = DBIO.seq(
uacSetupActions,
executorSetupOptions
)
database.run(initActions).map(_ =>
DatabaseLayer(uacModule, executorModule)
)

View file

@ -0,0 +1,3 @@
package db.internal
class NotFoundException extends Throwable

View file

@ -0,0 +1,7 @@
package db.modules
import slick.dbio.DBIO
import slick.jdbc.JdbcProfile
trait DatabaseModule:
def setup: DBIO[Unit]

View file

@ -0,0 +1,16 @@
package db.modules.executor
import slick.jdbc.JdbcProfile
import db.modules.DatabaseModule
import scala.concurrent.Future
class ExecutorModule(
profile: JdbcProfile,
database: profile.backend.Database
) extends DatabaseModule:
import profile.api._
def execute[A](query: DBIO[A]): Future[A] =
database.run(query)
def setup: DBIO[Unit] = DBIO.successful(None)

View file

@ -0,0 +1,129 @@
package db.modules.uac
import db.internal.NotFoundException
import db.modules.uac.{UACSchema, UACQueries}
import twitch.api.{TwitchUID, UserAuthenticationCredential, TokenScope}
import scala.concurrent.Future
import slick.jdbc.JdbcProfile
import scala.concurrent.ExecutionContext
class UACAPI(
val profile: JdbcProfile,
val queries: UACQueries,
val database: profile.backend.Database
):
import profile.api._
def setup: DBIO[Unit] =
DBIO.seq(
(queries.UACBase.schema ++ queries.tokenScopeBase.schema).createIfNotExists
)
def create(cred: UserAuthenticationCredential)(implicit ec: ExecutionContext): DBIO[Option[Int]] =
queries.UACBase.returning(queries.UACBase.map(_.id)).+=(
queries.schema.RawUAC(
0,
cred.userId,
cred.accessToken,
cred.refreshToken,
cred.expires
)
).flatMap(uacId =>
queries.tokenScopeBase.++=(
cred.scopes.map(scope =>
queries.schema.UACTokenScope(
uacId,
scope.uid
)
)
)
)
def retrieve(user: TwitchUID)(implicit ec: ExecutionContext): DBIO[UserAuthenticationCredential] =
// Construct query for base UAC data
queries.UACBase.filter(
_.userId === user
).take(1)
.result.headOption // Take only the first UAC that matches (there should never be more than one)
.flatMap(res => // With the result of that query:
res match
case None => slick.dbio.FailureAction(NotFoundException()) // Don't attempt to retrieve scopes if there's no matching UAC
case Some(uac) =>
// Construct a query to retrieve relevant scopes
queries.tokenScopeBase.filter( scope =>
scope.tokenId === uac.id
).result.map(res =>
val convertedScopes = res.map(scope =>
// Convert UIDs to TokenScope enums
TokenScope.fromUid(scope.scopeId)
).filter(mappedScope =>
// Filter any UIDs that didn't match TokenScopes.
mappedScope match
case None => false
case _ => true
).map(convertedScope =>
// Strip the Options.
convertedScope.get
)
UserAuthenticationCredential(
uac.userId,
uac.accessToken,
uac.refreshToken,
uac.expires,
convertedScopes.toList
)
)
)
// val action = database.run(query).flatMap( res =>
// res match
// case None => Future{None}
// case Some(uac) =>
// // Construct query for
// val scopesQuery = queries.tokenScopeBase.filter( scope =>
// scope.tokenId === uac.id
// ).result.map(res =>
// res.map(scope =>
// // Convert UIDs to TokenScope enums
// TokenScope.fromUid(scope.scopeId)
// ).filter(mappedScope =>
// // Filter any UIDs that didn't match TokenScopes.
// mappedScope match
// case None => false
// case _ => true
// ).map(convertedScope =>
// // Strip the Options.
// convertedScope.get
// )
// )
// database.run(scopesQuery).map(res =>
// UserAuthenticationCredential(
// uac.userId,
// uac.accessToken,
// uac.refreshToken,
// uac.expires,
// res.toList
// )
// )
// )
// .map((res) =>
// val queryResult: Option[queries.schema.RawUAC] =
// if res.length > 0 then Some(res[0]) else None
// if queryResult == None then
// throw Exception
// val scopesQuery = queries.tokenScopeBase.filter(
// _.tokenId === queryResult.accessToken
// ).take(1).result
// )
// action
def delete(user: TwitchUID): Future[Unit] = ???

View file

@ -0,0 +1,18 @@
package db.modules.uac
import db.modules.DatabaseModule
import db.modules.uac.{UACAPI, UACQueries, UACSchema}
import slick.jdbc.JdbcProfile
import slick.dbio.DBIO
class UACModule(
val profile: JdbcProfile,
val database: profile.backend.Database
) extends DatabaseModule:
val schema = UACSchema(profile)
val queries = UACQueries(profile, schema)
val api = UACAPI(profile, queries, database)
def setup: DBIO[Unit] =
api.setup

View file

@ -0,0 +1,13 @@
package db.modules.uac
import db.modules.uac.UACSchema
import slick.jdbc.JdbcProfile
class UACQueries(
val profile: JdbcProfile,
val schema: UACSchema
):
import profile.api._
val UACBase = schema._uacTable
val tokenScopeBase = schema._uacTokenScopeTable

View file

@ -0,0 +1,88 @@
/*
UNIT_CA5 - Stream management bot
Copyright (C) 2024 digimint
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package db.modules.uac
import twitch.api.TwitchUID
import twitch.api.AccessToken
import twitch.api.RefreshToken
import java.time.Instant
import slick.jdbc.JdbcProfile
class UACSchema(val profile: JdbcProfile):
import profile.api._
case class RawUAC(
id: Long = 0,
userId: TwitchUID,
accessToken: AccessToken,
refreshToken: RefreshToken,
expires: Instant
)
case class UACTokenScope(
tokenId: Long,
scopeId: Long
)
class UACTable(tag: Tag) extends Table[RawUAC](tag, "USER_TOKENS"):
def id = column[Long]("TKN_ID", O.PrimaryKey, O.AutoInc)
def userId = column[TwitchUID]("USER_ID")
def accessToken = column[AccessToken]("ACCESS_TKN")
def refreshToken = column[RefreshToken]("REFRESH_TKN")
def expires = column[Instant]("EXPIRES")
def * = (id, userId, accessToken, refreshToken, expires).mapTo[RawUAC]
val _uacTable = TableQuery[UACTable]
class UACTokenScopeTable(tag: Tag) extends Table[UACTokenScope](tag, "USER_TOKEN_SCOPES"):
def tokenId = column[Long]("TKN_ID")
def scopeId = column[Long]("SCOPE")
def token = foreignKey(
"TKN",
tokenId,
_uacTable
)(
_.id,
onDelete=ForeignKeyAction.Cascade
)
def * = (tokenId, scopeId).mapTo[UACTokenScope]
val _uacTokenScopeTable = TableQuery[UACTokenScopeTable]
// object api:
// def setup: DBIO[Unit] =
// DBIO.seq(
// (queries.UACBase.schema ++ queries.tokenScopeBase.schema).createIfNotExists
// )
// val queryScopesForCredential =
// tokenScopesQuery.join(rawUACs).on(_.tokenId === _.id)
// .groupBy(
// (token, cred) => cred.userId
// )
// .map(
// (userId, group) => userId -> group.map(
// (scope, user) => scope.scopeId
// )
// )

View file

@ -1,65 +0,0 @@
/*
UNIT_CA5 - Stream management bot
Copyright (C) 2024 digimint
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package unit_ca5.db.schema
import unit_ca5.twitch.TokenScope
trait ScopesVersion:
val scopes: List[TokenScope]
val version: Int
def toScopes(): List[TokenScope] = scopes
def toInt(): Int = version
def matches(version: Int): Boolean =
this.version == version
def matches(scopes: List[TokenScope]): Boolean =
this.scopes == scopes
object ScopesVersion:
val allScopesVersions: List[ScopesVersion] = List(
scopesVersion1
)
// Construct from a list of scopes
def apply(scopes: List[TokenScope]): Option[ScopesVersion] =
allScopesVersions.find(
(candidate: ScopesVersion) =>
candidate.matches(scopes)
)
// Construct from a version number
def apply(version: Int): Option[ScopesVersion] =
allScopesVersions.find(
(candidate: ScopesVersion) =>
candidate.matches(version)
)
val scopesVersion1: ScopesVersion =
new ScopesVersion:
val scopes = List(
TokenScope.UserReadChat,
TokenScope.BitsRead,
TokenScope.ChannelManageRaids
)
val version = 1

View file

@ -1,39 +0,0 @@
/*
UNIT_CA5 - Stream management bot
Copyright (C) 2024 digimint
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package unit_ca5.db.schema
import slick.jdbc.PostgresProfile.api._
import unit_ca5.twitch.UserAuthenticationCredential
import unit_ca5.twitch.TokenScope
import java.time.Instant
class UserAuthenticationCredentialTable(tag: Tag) extends Table[UserAuthenticationCredential](tag, "USER_TOKENS"):
def id = column[Int]("TKN_ID", O.PrimaryKey, O.AutoInc)
def userId = column[String]("USER_ID")
def accessToken = column[String]("ACCESS_TKN")
def refreshToken = column[String]("REFRESH_TKN")
def expires = column[Instant]("EXPIRES")
// Compact representation of the scopes granted to this token. If a version
// update changes the set of required scopes, the "current" SCOPES_VERSION
// is incremented. This then gets mapped to a set of actual scopes, which
// other functions can check.
def scopes_version = column[Int]("SCOPES_VERSION")
def * = ???

View file

@ -15,7 +15,7 @@
You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package unit_ca5.twitch package twitch.api
/** /**
* Enumeration of all scopes currently supported by the Twitch API * Enumeration of all scopes currently supported by the Twitch API
@ -24,7 +24,7 @@ package unit_ca5.twitch
* tokens with scopes in the database. The ordering of scopes within this enum * tokens with scopes in the database. The ordering of scopes within this enum
* is unimportant, as long as each scope's UID is unique and does not change. * is unimportant, as long as each scope's UID is unique and does not change.
*/ */
enum TokenScope(val uid: Int): enum TokenScope(val uid: Long):
// API Scopes // API Scopes
case AnalyticsReadExtensions extends TokenScope(0x0000) case AnalyticsReadExtensions extends TokenScope(0x0000)
case AnalyticsReadGames extends TokenScope(0x0001) case AnalyticsReadGames extends TokenScope(0x0001)
@ -106,3 +106,9 @@ enum TokenScope(val uid: Int):
case WhispersRead extends TokenScope(0x7030) case WhispersRead extends TokenScope(0x7030)
case WhispersEdit extends TokenScope(0x7031) case WhispersEdit extends TokenScope(0x7031)
object TokenScope:
def fromUid(uid: Long): Option[TokenScope] =
TokenScope.values.foldLeft(None)(
(z, thisScope) => if(thisScope.uid == uid) Some(thisScope) else z
)

View file

@ -1,36 +1,36 @@
/* /*
UNIT_CA5 - Stream management bot UNIT_CA5 - Stream management bot
Copyright (C) 2024 digimint Copyright (C) 2024 digimint
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details. GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package unit_ca5.twitch package twitch.api
import java.time.Instant import java.time.Instant
import unit_ca5.twitch.TokenScope import twitch.api.TokenScope
type TwitchUID = String type TwitchUID = String
type AccessToken = String type AccessToken = String
type RefreshToken = String type RefreshToken = String
case class UserAuthenticationCredential( case class UserAuthenticationCredential(
userId: TwitchUID, userId : TwitchUID,
accessToken: AccessToken, accessToken : AccessToken,
refreshToken: RefreshToken, refreshToken : RefreshToken,
expires: Instant, expires : Instant,
scopes: List[TokenScope] scopes : List[TokenScope]
): ):
def is_expired(now: Instant): Boolean = def is_expired(now: Instant): Boolean =
now.isAfter(expires) now.isAfter(expires)
@ -39,4 +39,6 @@ case class UserAuthenticationCredential(
scopes.contains(scope) scopes.contains(scope)
def supportsAll(scopeList: List[TokenScope]): Boolean = def supportsAll(scopeList: List[TokenScope]): Boolean =
scopeList.forall(
scopes.contains
)