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
Copyright (C) 2024 digimint
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 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.
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/>.
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
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.duration.Duration
import com.typesafe.config.ConfigFactory
import com.typesafe.config.Config
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(msg)
@ -33,71 +40,107 @@ import java.time.Instant
println(s"Test config key: ${test_key}")
Await.result(DBConnection.doSetup, Duration.Inf)
println("Database initialized")
println("Initializing database")
val dbLayer = Await.result(
DatabaseLayer.setup(
PostgresProfile,
"db_conf"
),
Duration.Inf
)
Await.result(DBConnection.doTestInserts, Duration.Inf)
println("Test values inserted")
println("Performing some test inserts...")
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. :)"
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 userId = column[String]("USER_ID")
def accessToken = column[String]("ACCESS_TKN")
def refreshToken = column[String]("REFRESH_TKN")
def expires = column[Instant]("EXPIRES")
// 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 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")
// // 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 * = (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"):
def userId = column[String]("USER_ID", O.PrimaryKey)
def name = column[String]("TWITCH_USERNAME")
// class AuthUsers(tag: Tag) extends Table[(String, String)](tag, "USERS"):
// def userId = column[String]("USER_ID", O.PrimaryKey)
// def name = column[String]("TWITCH_USERNAME")
def * = (userId, name)
// def * = (userId, name)
val authUsers = TableQuery[AuthUsers]
// val authUsers = TableQuery[AuthUsers]
object DBConnection{
val db = Database.forConfig("db_conf")
// object DBConnection{
// val db = Database.forConfig("db_conf")
val setup = DBIO.seq(
(authUsers.schema ++ userTokens.schema).createIfNotExists
)
// val setup = DBIO.seq(
// (authUsers.schema ++ userTokens.schema).createIfNotExists
// )
def doSetup =
db.run(setup)
// def doSetup =
// db.run(setup)
def doTestInserts =
val testInserts = DBIO.seq(
authUsers ++= Seq(
("a", "digimint"),
("b", "PancakeDragoness")
),
// def doTestInserts =
// val testInserts = DBIO.seq(
// authUsers ++= Seq(
// ("a", "digimint"),
// ("b", "PancakeDragoness")
// ),
userTokens ++= Seq(
(1, "a", "coolAccessToken", "coolRefreshToken", Instant.now, 1),
(2, "b", "hotAccessToken", "hotRefreshToken", Instant.now, 1)
)
)
// userTokens ++= Seq(
// (1, "a", "coolAccessToken", "coolRefreshToken", Instant.now, 1),
// (2, "b", "hotAccessToken", "hotRefreshToken", Instant.now, 1)
// )
// )
db.run(testInserts)
// val userInserts: DBIO[Option[Int]] = authUsers ++= Seq (
// ("a", "digimint"),
// ("b", "PancakeDragoness")
// )
// db.run(testInserts)
// // val userInserts: DBIO[Option[Int]] = authUsers ++= Seq (
// // ("a", "digimint"),
// // ("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
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
@ -24,7 +24,7 @@ package unit_ca5.twitch
* 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.
*/
enum TokenScope(val uid: Int):
enum TokenScope(val uid: Long):
// API Scopes
case AnalyticsReadExtensions extends TokenScope(0x0000)
case AnalyticsReadGames extends TokenScope(0x0001)
@ -105,4 +105,10 @@ enum TokenScope(val uid: Int):
case UserWriteChat extends TokenScope(0x7022)
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
Copyright (C) 2024 digimint
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 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.
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/>.
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.twitch
package twitch.api
import java.time.Instant
import unit_ca5.twitch.TokenScope
import twitch.api.TokenScope
type TwitchUID = String
type AccessToken = String
type RefreshToken = String
case class UserAuthenticationCredential(
userId: TwitchUID,
accessToken: AccessToken,
refreshToken: RefreshToken,
expires: Instant,
scopes: List[TokenScope]
userId : TwitchUID,
accessToken : AccessToken,
refreshToken : RefreshToken,
expires : Instant,
scopes : List[TokenScope]
):
def is_expired(now: Instant): Boolean =
now.isAfter(expires)
@ -39,4 +39,6 @@ case class UserAuthenticationCredential(
scopes.contains(scope)
def supportsAll(scopeList: List[TokenScope]): Boolean =
scopeList.forall(
scopes.contains
)