diff --git a/composeApp/src/androidMain/kotlin/com/derrakuma/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/derrakuma/MainActivity.kt index 404f721..f0984ed 100644 --- a/composeApp/src/androidMain/kotlin/com/derrakuma/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/derrakuma/MainActivity.kt @@ -54,7 +54,7 @@ class MainActivity : ComponentActivity() { private fun handleAtProtoRedirect(intent: Intent) { val uri = intent.data ?: return - if (uri.scheme == "com.derrakuma" && uri.host == "oauth-redirect") { + if (uri.scheme == "com.derrakuma" && uri.path == "/oauth-redirect") { val client = DerrakumaApp.atProtoClient as? AndroidAtProtoClient ?: return val redirectUri = uri.toString() Log.d("MainActivity", "OAuth redirect received: $redirectUri") diff --git a/composeApp/src/androidMain/kotlin/com/derrakuma/atproto/AndroidAtProtoClient.kt b/composeApp/src/androidMain/kotlin/com/derrakuma/atproto/AndroidAtProtoClient.kt index 70876e2..65825db 100644 --- a/composeApp/src/androidMain/kotlin/com/derrakuma/atproto/AndroidAtProtoClient.kt +++ b/composeApp/src/androidMain/kotlin/com/derrakuma/atproto/AndroidAtProtoClient.kt @@ -42,6 +42,7 @@ class AndroidAtProtoClient( redirectUri = "$redirectScheme:/oauth-redirect", sessionStore = sessionStore, httpClient = httpClient, + scope = DERRAKUMA_ATPROTO_SCOPE, ) private var xrpcClient: XrpcClient? = null diff --git a/composeApp/src/androidMain/kotlin/com/derrakuma/atproto/AtProtoFactory.android.kt b/composeApp/src/androidMain/kotlin/com/derrakuma/atproto/AtProtoFactory.android.kt index df3de0f..43b2235 100644 --- a/composeApp/src/androidMain/kotlin/com/derrakuma/atproto/AtProtoFactory.android.kt +++ b/composeApp/src/androidMain/kotlin/com/derrakuma/atproto/AtProtoFactory.android.kt @@ -5,7 +5,7 @@ actual fun createAtProtoClient(): AtProtoClient { // Called lazily; the AndroidApp companion provides the context return AndroidAtProtoClient( context = com.derrakuma.DerrakumaApp.context, - clientMetadataUrl = "https://milk.derrakuma.com/oauth/client-metadata.json", + clientMetadataUrl = "https://derrakuma.com/oauth/client-metadata.json", redirectScheme = "com.derrakuma", ) } diff --git a/composeApp/src/commonMain/kotlin/com/derrakuma/atproto/AtProtoClient.kt b/composeApp/src/commonMain/kotlin/com/derrakuma/atproto/AtProtoClient.kt index adbf503..ff45495 100644 --- a/composeApp/src/commonMain/kotlin/com/derrakuma/atproto/AtProtoClient.kt +++ b/composeApp/src/commonMain/kotlin/com/derrakuma/atproto/AtProtoClient.kt @@ -2,10 +2,19 @@ package com.derrakuma.atproto import kotlinx.serialization.json.JsonObject +const val DERRAKUMA_ATPROTO_SCOPE = + "atproto " + + "repo:com.derrakuma.profile?action=create " + + "repo:com.derrakuma.profile?action=update " + + "repo:com.derrakuma.play?action=create " + + "repo:com.derrakuma.best?action=create " + + "repo:com.derrakuma.best?action=update " + + "repo:com.derrakuma.friend?action=create" + /** * Platform-agnostic ATProto client interface. * Android: wraps atproto-kotlin's XrpcClient. - * iOS: stub (atproto-kotlin doesn't publish iOS klibs yet). + * iOS: native KMP OAuth/DPoP client. */ interface AtProtoClient { val isAvailable: Boolean diff --git a/composeApp/src/commonMain/kotlin/com/derrakuma/atproto/HappyViewSocialClient.kt b/composeApp/src/commonMain/kotlin/com/derrakuma/atproto/HappyViewSocialClient.kt index b90a474..e223799 100644 --- a/composeApp/src/commonMain/kotlin/com/derrakuma/atproto/HappyViewSocialClient.kt +++ b/composeApp/src/commonMain/kotlin/com/derrakuma/atproto/HappyViewSocialClient.kt @@ -8,7 +8,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json private const val HAPPYVIEW_BASE_URL = "https://milk.derrakuma.com" -private const val HAPPYVIEW_CLIENT_KEY = "derrakuma-app" +private const val HAPPYVIEW_CLIENT_KEY = "hvc_cfd543519c512cb3328ad4cbf735a542" private val happyViewJson = Json { ignoreUnknownKeys = true diff --git a/composeApp/src/commonMain/kotlin/com/derrakuma/atproto/vendor/oauth/AtOAuth.kt b/composeApp/src/commonMain/kotlin/com/derrakuma/atproto/vendor/oauth/AtOAuth.kt index 616117c..8d58af7 100644 --- a/composeApp/src/commonMain/kotlin/com/derrakuma/atproto/vendor/oauth/AtOAuth.kt +++ b/composeApp/src/commonMain/kotlin/com/derrakuma/atproto/vendor/oauth/AtOAuth.kt @@ -49,10 +49,9 @@ import kotlin.io.encoding.ExperimentalEncodingApi * * ## Requesting additional scopes * - * To request umbrella scopes beyond the default `atproto transition:generic` - * (e.g. `transition:chat.bsky` for Bluesky chat, `transition:email` for - * email), pass them via [scope]. The value must be a subset of the `scope` - * field declared in the hosted `client-metadata.json` document. + * To request scopes beyond the default identity-only `atproto` scope, pass + * them via [scope]. The value must be a subset of the `scope` field declared + * in the hosted `client-metadata.json` document. * * ```kotlin * val oauth = AtOAuth( @@ -60,7 +59,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi * redirectUri = "app.example:/oauth-redirect", * sessionStore = ..., * httpClient = ..., - * scope = "atproto transition:generic transition:chat.bsky", + * scope = "atproto repo:app.example.record?action=create", * ) * ``` * @@ -73,7 +72,7 @@ class AtOAuth( private val sessionStore: OAuthSessionStore, private val httpClient: HttpClient, private val json: Json = Json { ignoreUnknownKeys = true }, - private val scope: String = "atproto transition:generic", + private val scope: String = "atproto", ) { // Transient state during the login/signup flow (between beginX and completeLogin) private var pendingState: PendingAuthState? = null diff --git a/composeApp/src/iosMain/kotlin/com/derrakuma/atproto/AtProtoFactory.ios.kt b/composeApp/src/iosMain/kotlin/com/derrakuma/atproto/AtProtoFactory.ios.kt index 4f7b7d1..361d0b2 100644 --- a/composeApp/src/iosMain/kotlin/com/derrakuma/atproto/AtProtoFactory.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/derrakuma/atproto/AtProtoFactory.ios.kt @@ -1,6 +1,6 @@ package com.derrakuma.atproto actual fun createAtProtoClient(): AtProtoClient = IosAtProtoClient( - clientMetadataUrl = "https://milk.derrakuma.com/oauth/client-metadata.json", + clientMetadataUrl = "https://derrakuma.com/oauth/client-metadata.json", redirectScheme = "com.derrakuma", ) diff --git a/composeApp/src/iosMain/kotlin/com/derrakuma/atproto/IosAtProtoClient.kt b/composeApp/src/iosMain/kotlin/com/derrakuma/atproto/IosAtProtoClient.kt index 0b544e1..e637d76 100644 --- a/composeApp/src/iosMain/kotlin/com/derrakuma/atproto/IosAtProtoClient.kt +++ b/composeApp/src/iosMain/kotlin/com/derrakuma/atproto/IosAtProtoClient.kt @@ -32,6 +32,7 @@ class IosAtProtoClient( redirectUri = "$redirectScheme:/oauth-redirect", sessionStore = sessionStore, httpClient = httpClient, + scope = DERRAKUMA_ATPROTO_SCOPE, ) private var xrpcClient: XrpcClient? = null diff --git a/deploy/happyview/mobile.patch b/deploy/happyview/mobile.patch index e98a6e0..dedc956 100644 --- a/deploy/happyview/mobile.patch +++ b/deploy/happyview/mobile.patch @@ -1,5 +1,5 @@ diff --git a/src/server.rs b/src/server.rs -index 479c67c..d40ae31 100644 +index 479c67c..31b1ce1 100644 --- a/src/server.rs +++ b/src/server.rs @@ -70,6 +70,7 @@ pub fn router(state: AppState) -> Router { @@ -10,24 +10,40 @@ index 479c67c..d40ae31 100644 .nest("/oauth", crate::oauth::routes::routes()) // https://atproto.com/specs/oauth#types-of-clients .route("/oauth-client-metadata.json", get(client_metadata)) -@@ -251,6 +252,25 @@ async fn config_endpoint( +@@ -251,6 +252,41 @@ async fn config_endpoint( })) } +async fn derrakuma_mobile_client_metadata( -+ State(state): State, ++ req: axum::extract::Request, +) -> Json { -+ let public_url = state.config.url_with_base_path(&state.config.public_url); ++ let public_url = req ++ .headers() ++ .get(header::HOST) ++ .and_then(|host| host.to_str().ok()) ++ .map(|host| format!("https://{}", host.trim_end_matches("/"))) ++ .unwrap_or_else(|| "https://derrakuma.com".to_string()); + let public_url = public_url.trim_end_matches("/"); ++ let scope = [ ++ "atproto", ++ "repo:com.derrakuma.profile?action=create", ++ "repo:com.derrakuma.profile?action=update", ++ "repo:com.derrakuma.play?action=create", ++ "repo:com.derrakuma.best?action=create", ++ "repo:com.derrakuma.best?action=update", ++ "repo:com.derrakuma.friend?action=create", ++ ] ++ .join(" "); + + Json(serde_json::json!({ + "client_id": format!("{}/oauth/client-metadata.json", public_url), + "client_name": "Derrakuma", + "client_uri": public_url, -+ "redirect_uris": ["derrakuma://oauth-redirect"], ++ "application_type": "native", ++ "redirect_uris": ["com.derrakuma:/oauth-redirect"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], -+ "scope": "atproto transition:generic", ++ "scope": scope, + "token_endpoint_auth_method": "none", + "dpop_bound_access_tokens": true + })) @@ -35,4 +51,4 @@ index 479c67c..d40ae31 100644 + async fn client_metadata( State(state): State, - req: axum::extract::Request, \ No newline at end of file + req: axum::extract::Request, diff --git a/scripts/install_happyview_social_lexicons.py b/scripts/install_happyview_social_lexicons.py new file mode 100644 index 0000000..19473ed --- /dev/null +++ b/scripts/install_happyview_social_lexicons.py @@ -0,0 +1,270 @@ +import json +import sqlite3 +from datetime import datetime, timezone + + +QUERIES = { + "com.derrakuma.getFriendsPlays": ( + { + "lexicon": 1, + "id": "com.derrakuma.getFriendsPlays", + "defs": { + "main": { + "type": "query", + "parameters": { + "type": "params", + "properties": { + "actor": {"type": "string", "format": "did"}, + "limit": {"type": "integer"}, + "cursor": {"type": "string"}, + }, + }, + "output": {"encoding": "application/json"}, + } + }, + }, + r''' +function value_of(record) + return record.value or record +end + +function handle() + local actor = params.actor or caller_did + local limit = params.limit or 50 + if actor == nil or actor == '' then + return { records = toarray({}) } + end + + local friends = db.query({ collection = 'com.derrakuma.friend', did = actor, limit = 100 }).records + local out = {} + for _, friend in ipairs(friends) do + local friend_value = value_of(friend) + local subject = friend_value.subject + if subject ~= nil and subject ~= '' then + local profiles = db.query({ collection = 'com.derrakuma.profile', did = subject, limit = 1 }).records + local profile = nil + if #profiles > 0 then profile = value_of(profiles[1]) end + local plays = db.query({ collection = 'com.derrakuma.play', did = subject, limit = limit }).records + for _, play in ipairs(plays) do + local play_value = value_of(play) + play_value.did = play.did + play_value.uri = play.uri + if profile ~= nil then + play_value.playerName = profile.playerName + play_value.playerRating = profile.rating + else + play_value.playerName = friend_value.displayName + end + table.insert(out, play_value) + end + end + end + + table.sort(out, function(a, b) + return tostring(a.createdAt or '') > tostring(b.createdAt or '') + end) + + local trimmed = {} + for i = 1, math.min(#out, limit) do + table.insert(trimmed, out[i]) + end + return { records = toarray(trimmed) } +end +''', + ), + "com.derrakuma.getLeaderboard": ( + { + "lexicon": 1, + "id": "com.derrakuma.getLeaderboard", + "defs": { + "main": { + "type": "query", + "parameters": { + "type": "params", + "required": ["songName", "difficulty"], + "properties": { + "songName": {"type": "string"}, + "difficulty": {"type": "string"}, + "limit": {"type": "integer"}, + "cursor": {"type": "string"}, + }, + }, + "output": {"encoding": "application/json"}, + } + }, + }, + r''' +function value_of(record) + return record.value or record +end + +function handle() + local song = params.songName or '' + local diff = params.difficulty or '' + local limit = params.limit or 50 + local rows = db.query({ collection = 'com.derrakuma.best', limit = 100 }).records + local out = {} + + for _, row in ipairs(rows) do + local value = value_of(row) + if value.songName == song and value.difficulty == diff then + value.did = row.did + value.uri = row.uri + local profiles = db.query({ collection = 'com.derrakuma.profile', did = row.did, limit = 1 }).records + if #profiles > 0 then + local profile = value_of(profiles[1]) + value.playerName = profile.playerName + value.playerRating = profile.rating + end + table.insert(out, value) + end + end + + table.sort(out, function(a, b) + local aa = tonumber(a.achievement or 0) or 0 + local bb = tonumber(b.achievement or 0) or 0 + if aa == bb then + return tostring(a.updatedAt or '') > tostring(b.updatedAt or '') + end + return aa > bb + end) + + local trimmed = {} + for i = 1, math.min(#out, limit) do + out[i].rank = i + table.insert(trimmed, out[i]) + end + return { records = toarray(trimmed) } +end +''', + ), + "com.derrakuma.getPlayerStats": ( + { + "lexicon": 1, + "id": "com.derrakuma.getPlayerStats", + "defs": { + "main": { + "type": "query", + "parameters": { + "type": "params", + "properties": { + "actor": {"type": "string", "format": "did"}, + }, + }, + "output": {"encoding": "application/json"}, + } + }, + }, + r''' +function value_of(record) + return record.value or record +end + +function handle() + local actor = params.actor or caller_did + if actor == nil or actor == '' then + return { profile = nil, playCount = 0, bestCount = 0, averageAchievement = 0 } + end + + local profiles = db.query({ collection = 'com.derrakuma.profile', did = actor, limit = 1 }).records + local profile = nil + if #profiles > 0 then + profile = value_of(profiles[1]) + profile.uri = profiles[1].uri + profile.did = profiles[1].did + end + + local play_count = db.count('com.derrakuma.play', actor) + local bests = db.query({ collection = 'com.derrakuma.best', did = actor, limit = 100 }).records + local total = 0 + for _, best in ipairs(bests) do + local value = value_of(best) + total = total + (tonumber(value.achievement or 0) or 0) + end + local avg = 0 + if #bests > 0 then avg = total / #bests end + + return { profile = profile, playCount = play_count, bestCount = #bests, averageAchievement = avg } +end +''', + ), + "com.derrakuma.searchProfiles": ( + { + "lexicon": 1, + "id": "com.derrakuma.searchProfiles", + "defs": { + "main": { + "type": "query", + "parameters": { + "type": "params", + "required": ["query"], + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer"}, + }, + }, + "output": {"encoding": "application/json"}, + } + }, + }, + r''' +function value_of(record) + return record.value or record +end + +function handle() + local q = params.query or '' + local limit = params.limit or 20 + if q == '' then + return { records = toarray({}) } + end + + local rows = db.search({ collection = 'com.derrakuma.profile', field = 'playerName', query = q, limit = limit }).records + local out = {} + for _, row in ipairs(rows) do + local value = value_of(row) + value.did = row.did + value.uri = row.uri + table.insert(out, value) + end + return { records = toarray(out) } +end +''', + ), +} + + +def main() -> None: + con = sqlite3.connect("data/happyview.db") + now = datetime.now(timezone.utc).isoformat() + for nsid, (lexicon, script) in QUERIES.items(): + con.execute( + """ + INSERT INTO lexicons (id, revision, lexicon_json, backfill, target_collection, action, script, source, created_at, updated_at) + VALUES (?, 1, ?, 0, ?, NULL, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + lexicon_json = excluded.lexicon_json, + backfill = excluded.backfill, + target_collection = excluded.target_collection, + script = excluded.script, + revision = lexicons.revision + 1, + source = excluded.source, + updated_at = excluded.updated_at + """, + (nsid, json.dumps(lexicon, separators=(",", ":")), nsid, script, "manual", now, now), + ) + print("upserted", nsid) + con.commit() + print( + "query lexicons", + con.execute( + "select count(*) from lexicons where id in ({})".format( + ",".join("?" for _ in QUERIES) + ), + tuple(QUERIES.keys()), + ).fetchone()[0], + ) + + +if __name__ == "__main__": + main() diff --git a/site/.well-known-atproto-did b/site/.well-known-atproto-did new file mode 100644 index 0000000..7b455bf --- /dev/null +++ b/site/.well-known-atproto-did @@ -0,0 +1 @@ +did:plc:4uoc2as443j2fg2f6xfsogqs diff --git a/site/.well-known/atproto-did b/site/.well-known/atproto-did new file mode 100644 index 0000000..7b455bf --- /dev/null +++ b/site/.well-known/atproto-did @@ -0,0 +1 @@ +did:plc:4uoc2as443j2fg2f6xfsogqs diff --git a/site/README.md b/site/README.md new file mode 100644 index 0000000..2f7c744 --- /dev/null +++ b/site/README.md @@ -0,0 +1,12 @@ +# derrakuma.com static site + +Static files for the root `derrakuma.com` host. + +Upload the contents of this directory to the static host serving `https://derrakuma.com`. + +Required for ATProto mobile OAuth and authority discovery: + +- `https://derrakuma.com/oauth/client-metadata.json` +- `https://derrakuma.com/.well-known/atproto-did` + +The HappyView AppView/dashboard remains hosted at `https://milk.derrakuma.com`. diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..39b4107 --- /dev/null +++ b/site/index.html @@ -0,0 +1,18 @@ + + + + + + Derrakuma + + + +

Derrakuma

+

AT Protocol metadata for Derrakuma.

+ + + diff --git a/site/oauth/client-metadata.json b/site/oauth/client-metadata.json new file mode 100644 index 0000000..b88e297 --- /dev/null +++ b/site/oauth/client-metadata.json @@ -0,0 +1,19 @@ +{ + "client_id": "https://derrakuma.com/oauth/client-metadata.json", + "client_name": "Derrakuma", + "client_uri": "https://derrakuma.com", + "application_type": "native", + "redirect_uris": [ + "com.derrakuma:/oauth-redirect" + ], + "grant_types": [ + "authorization_code", + "refresh_token" + ], + "response_types": [ + "code" + ], + "scope": "atproto repo:com.derrakuma.profile?action=create repo:com.derrakuma.profile?action=update repo:com.derrakuma.play?action=create repo:com.derrakuma.best?action=create repo:com.derrakuma.best?action=update repo:com.derrakuma.friend?action=create", + "token_endpoint_auth_method": "none", + "dpop_bound_access_tokens": true +}