Porównaj commity

...

46 Commity
v0.2.3 ... main

Autor SHA1 Wiadomość Data
Namekuji 7adba191e6
Add link to Hamabē 2024-02-15 21:57:46 -05:00
Namekuji 3cc4ee4f84
use native 2023-07-02 12:04:34 -04:00
Namekuji a606f7f4fb
remove png 2023-07-02 11:51:52 -04:00
Namekuji 1bb384a10c
fix bot url 2023-07-02 02:43:02 -04:00
Namekuji 0fc4e5fbec
update introduction 2023-05-28 08:59:39 -04:00
Namekuji 30cc7930fc
disable avatar conversion 2023-05-28 08:36:24 -04:00
Namekuji 6c4ef49085
reduce sound effect volumes 2023-05-28 08:08:20 -04:00
Namekuji 85979a7ebf
remove avatar restore 2023-05-26 10:32:52 -04:00
Namekuji 6f7b58f2e2 fix mic status label 2023-04-28 21:16:23 -04:00
Namekuji 03e5cb4f02 bump version 2023-04-28 14:16:52 -04:00
Namekuji 87300ea112 remove docker 2023-04-28 14:15:43 -04:00
Namekuji 7b837651dd add aria-label to buttons missing content 2023-04-28 14:15:24 -04:00
Namekuji c40fb71147 use rootless 2023-04-28 13:32:50 -04:00
Namekuji d52ae18c44 disable avatar update 2023-04-28 09:31:40 -04:00
Namekuji 3138e2a596 fix returning string 2023-03-22 10:45:00 -04:00
Namekuji 80963c96e6 fix oauth redirect_uri 2023-03-22 10:14:00 -04:00
Namekuji 32dbfbc205 use png twemoji 2023-02-20 17:39:27 -05:00
Namekuji e2a5fb6fd6 fix import error 2023-02-20 15:12:47 -05:00
Namekuji f11d4f016a use twemoji 2023-02-20 15:08:35 -05:00
Namekuji fec8e75a74 remove error from url 2023-01-29 23:12:07 -05:00
Namekuji 150ad6df05 increase session cacche ttl 2023-01-29 21:30:10 -05:00
Namekuji db42f13ee4 fix cohost redirect 2023-01-28 23:48:25 -05:00
Namekuji 8dc11e7f84 fix host avatar restore 2023-01-28 23:33:16 -05:00
Namekuji 3ad0378bd2 bump version 2023-01-28 22:20:57 -05:00
Namekuji cff4f9eaab add role update 2023-01-28 22:16:21 -05:00
Namekuji 55ca35c3c8 allow cohosts to close the room 2023-01-28 15:08:15 -05:00
Namekuji d4d1ab6792 remove shceduling completely 2023-01-28 11:24:17 -05:00
Namekuji e1bbda00c8 remove scheduling 2023-01-28 11:20:00 -05:00
Namekuji 06ed20cd98 fix timezone 2023-01-28 09:41:59 -05:00
Namekuji 2b70a85bca add noise supression 2023-01-28 09:37:11 -05:00
Namekuji 6088af7101 avoid gif conversion 2023-01-28 08:21:01 -05:00
Namekuji a7f80e2ef0 revert audio capture options to default 2023-01-27 18:20:37 -05:00
Namekuji e28f2edb2e always convert original avatar to png to avoid recompression 2023-01-27 12:45:52 -05:00
Namekuji 6adbf5de60 change log message 2023-01-27 08:28:16 -05:00
Namekuji b571b4df30 revert index count 2023-01-27 01:52:04 -05:00
Namekuji 9543928276 use webfinger instead of acct 2023-01-27 01:22:40 -05:00
Namekuji abfb2f62f4 fix an issue that remote accounts cannot be added as cohosts 2023-01-27 01:11:37 -05:00
Namekuji 7028f5834c slightly change one sentence 2023-01-26 22:06:25 -05:00
Namekuji 8bfd860c24 fix unclear en sentence 2023-01-26 18:47:02 -05:00
Namekuji 63e2be37e2 fix unclear sentence 2023-01-26 18:45:14 -05:00
Namekuji ca77984520 bump version 2023-01-26 18:12:39 -05:00
Namekuji 2047c3b691 change url style 2023-01-26 17:43:04 -05:00
Namekuji 1a6b838c9c change timing to create room 2023-01-26 17:31:53 -05:00
Namekuji b21aa42aaa add static link 2023-01-26 14:39:19 -05:00
Namekuji 17e07d28a1 add static url 2023-01-26 13:28:25 -05:00
Namekuji 073510d4b5 fix ios regex error 2023-01-26 13:28:12 -05:00
38 zmienionych plików z 3218 dodań i 4213 usunięć

Wyświetl plik

@ -2,17 +2,21 @@ audon.localhost {
tls /etc/caddy/certs/cert.pem /etc/caddy/certs/key.pem
encode zstd gzip
@backend {
path /app/* /api/* /storage/*
path /app/* /api/* /storage/* /u/*
}
handle @backend {
reverse_proxy devcontainer:8100
reverse_proxy devcontainer:8100 {
flush_interval -1
}
}
handle {
reverse_proxy devcontainer:5173
reverse_proxy devcontainer:5173 {
flush_interval -1
}
}
}
livekit.audon.localhost {
livekit.localhost {
tls /etc/caddy/certs/cert.pem /etc/caddy/certs/key.pem
encode zstd gzip
reverse_proxy livekit:7880

Wyświetl plik

@ -6,7 +6,7 @@
// "image": "mcr.microsoft.com/devcontainers/universal:2-linux"
"dockerComposeFile": "docker-compose.dev.yaml",
"service": "devcontainer",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
"workspaceFolder": "/audon-go",
// Features to add to the dev container. More info: https://containers.dev/features.
"features": {
@ -33,8 +33,8 @@
"EditorConfig.EditorConfig"
]
}
}
},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
"remoteUser": "root"
}

Wyświetl plik

@ -2,10 +2,12 @@ version: '3.1'
services:
devcontainer:
image: "mcr.microsoft.com/devcontainers/base:jammy"
image: "mcr.microsoft.com/devcontainers/base:debian"
volumes:
- ../..:/workspaces:cached
- ..:/audon-go:cached
command: sleep infinity
environment:
- "DOCKER_HOST=unix:///run/user/1000/docker.sock"
db:
image: mongo:6

Wyświetl plik

@ -20,7 +20,7 @@ webhook:
room:
auto_create: false
empty_timeout: 3600
empty_timeout: 30
max_participants: 0
max_metadata_size: 0

Wyświetl plik

@ -28,6 +28,8 @@ LIVEKIT_API_SECRET=secret
LIVEKIT_HOST=livekit.example.com
# This value will be returned by Audon backend to browsers. Set the same domain as LIVEKIT_HOST if you are not sure.
LIVEKIT_LOCAL_DOMAIN=livekit.example.com
# If this period (seconds) passes, the new room will be automatically closed.
LIVEKIT_EMPTY_ROOM_TIMEOUT=300
### Bot Settings ###
# Leave the following fields empty to disable the notification bot.

2
.gitignore vendored
Wyświetl plik

@ -20,6 +20,7 @@
# Go workspace file
go.work
__debug_bin
### Node ###
# Logs
@ -65,6 +66,7 @@ build/Release
# Dependency directories
node_modules/
jspm_packages/
.pnpm-store/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/

Wyświetl plik

@ -4,8 +4,9 @@ WORKDIR /workspace
COPY audon-fe/ /workspace/
RUN npm install && \
npm run build
RUN npm install -g pnpm && \
pnpm install && \
pnpm run build
FROM golang:1.19-bullseye
@ -30,7 +31,7 @@ COPY --from=1 /workspace/audon-bin /audon/
COPY locales /audon/locales
COPY public /audon/public
RUN echo "Etc/UTC" > /etc/localtime && \
RUN echo "UTC" > /etc/localtime && \
apt-get update && apt-get upgrade -y && \
apt-get -y --no-install-recommends install \
imagemagick webp \

Wyświetl plik

@ -4,13 +4,19 @@
----
This repository is archived and will no longer be updated. Successor: [Hamabē](https://codeberg.org/hamabe/hamabe)
----
<div align="right">
<img src="audon-fe/src/assets/img/mascot.webp" alt="Mascot" width="150" align="right" title="Mascot designed by Taiyo Fujii" />
</div>
Audio + Mastodon = Audon
Audon is a service of realtime audio streaming for Mastodon.
Audon is a service of realtime audio chat for Mastodon, Akkoma, GoToSocial, and Calckey.
Other Fediverse platforms supporting Mastodon API may work, but not tested (yet).
## Tech Stack

Wyświetl plik

@ -29,7 +29,7 @@
{% end %}
</head>
<body>
<div id="app" data-version='0.2.3'></div>
<div id="app" data-version='0.3.2'></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

3765
audon-fe/package-lock.json wygenerowano

Plik diff jest za duży Load Diff

Wyświetl plik

@ -1,6 +1,6 @@
{
"name": "audon-fe",
"version": "0.2.3",
"version": "0.3.2",
"private": true,
"scripts": {
"dev": "cp -v index.dev.html index.html && vite",
@ -9,34 +9,35 @@
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
},
"dependencies": {
"@intlify/unplugin-vue-i18n": "^0.8.1",
"@picmo/popup-picker": "^5.7.2",
"@intlify/unplugin-vue-i18n": "^0.8.2",
"@picmo/popup-picker": "^5.7.6",
"@picmo/renderer-twemoji": "^5.7.6",
"@uriopass/nosleep.js": "^0.12.2",
"@vuelidate/core": "^2.0.0",
"@vuelidate/validators": "^2.0.0",
"@vueuse/core": "^9.6.0",
"axios": "^1.2.0",
"@vueuse/core": "^9.13.0",
"axios": "^1.3.3",
"howler": "^2.2.3",
"livekit-client": "^1.6.0",
"livekit-client": "^1.6.5",
"lodash-es": "^4.17.21",
"luxon": "^3.1.1",
"masto": "^5.6.0",
"picmo": "^5.7.2",
"pinia": "^2.0.26",
"vue": "^3.2.45",
"luxon": "^3.2.1",
"masto": "^5.10.0",
"picmo": "^5.7.6",
"pinia": "^2.0.31",
"vue": "^3.2.47",
"vue-i18n": "^9.2.2",
"vue-router": "^4.1.6",
"vuetify": "^3.0.3"
"vuetify": "^3.1.5"
},
"devDependencies": {
"@mdi/js": "^7.0.96",
"@rushstack/eslint-patch": "^1.1.4",
"@mdi/js": "^7.1.96",
"@rushstack/eslint-patch": "^1.2.0",
"@vitejs/plugin-vue": "^3.2.0",
"@vue/eslint-config-prettier": "^7.0.0",
"eslint": "^8.22.0",
"eslint-plugin-vue": "^9.3.0",
"prettier": "^2.7.1",
"vite": "^3.2.4",
"vite-plugin-vuetify": "^1.0.0"
"eslint": "^8.34.0",
"eslint-plugin-vue": "^9.9.0",
"prettier": "^2.8.4",
"vite": "^3.2.5",
"vite-plugin-vuetify": "^1.0.2"
}
}

Plik diff jest za duży Load Diff

Wyświetl plik

@ -48,7 +48,7 @@ export default {
id="mainArea"
>
<v-col>
<v-responsive class="mx-auto" max-width="600px">
<v-responsive id="mainContainer" class="mx-auto" max-width="600px">
<RouterView />
</v-responsive>
</v-col>
@ -106,4 +106,8 @@ export default {
background: black;
color: white;
}
#mainContainer {
background-color: rgba(18, 18, 18, 0.8);
}
</style>

Wyświetl plik

@ -29,7 +29,10 @@ body,
#app {
height: 100%;
width: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Hiragino Sans", "Noto Sans CJK JP", "Original Yu Gothic", "Yu Gothic", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Sans Emoji";
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Hiragino Sans", "Noto Sans CJK JP", "Original Yu Gothic", "Yu Gothic",
sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Sans Emoji";
}
a.plain {

Wyświetl plik

@ -3,14 +3,14 @@ import router from "../router";
export const validators = {
fqdn: helpers.regex(
/(?=^.{4,253}$)(^((?!-)[a-zA-Z0-9-]{1,63}(?<!-)\.)+[a-zA-Z]{2,63}$)/
/^([a-zA-Z0-9]{1}[a-zA-Z0-9-]{0,62})(\.[a-zA-Z0-9]{1}[a-zA-Z0-9-]{0,62})*?(\.[a-zA-Z]{1}[a-zA-Z0-9]{0,62})\.?$/
),
};
export function webfinger(user) {
if (!user) return "";
const url = new URL(user.url);
const finger = user.acct.split("@");
const finger = user.username.split("@");
return `${finger[0]}@${url.host}`;
}

Wyświetl plik

@ -28,7 +28,8 @@ export default {
},
computed: {
uploadEnabled() {
return this.roomToken?.original && this.roomToken?.indicator;
// return this.roomToken?.original && this.roomToken?.indicator;
return false;
},
},
async mounted() {
@ -89,7 +90,6 @@ export default {
async joining(indicator) {
try {
this.donStore.avatar = this.roomToken.original;
await this.roomClient.startAudio();
if (indicator && this.uploadEnabled) {
this.uploading = true;
try {

Wyświetl plik

@ -18,6 +18,7 @@ export default {
muted: Boolean,
emoji: String,
preview: Boolean,
enableMenu: Boolean,
},
computed: {
showEmoji() {
@ -34,17 +35,17 @@ export default {
switch (this.type) {
case "host":
return {
content: "Host",
content: this.$t("role.host"),
colour: "deep-orange",
};
case "cohost":
return {
content: "Cohost",
content: this.$t("role.cohost"),
colour: "indigo",
};
case "speaker":
return {
content: "Speaker",
content: this.$t("role.speaker"),
colour: "",
};
default:
@ -76,9 +77,15 @@ export default {
scrim="#000000"
class="align-center justify-center reaction"
>
<span>{{ emoji }}</span>
<div class="d-flex align-center justify-center">
<img class="emoji" :src="emoji" />
</div>
</v-overlay>
<v-img :src="data?.avatar"></v-img>
<v-img
:class="{ cursorPointer: enableMenu }"
:id="`mod-${data?.identity}`"
:src="data?.avatar"
></v-img>
</v-avatar>
</v-badge>
<v-avatar
@ -95,10 +102,38 @@ export default {
scrim="#000000"
class="align-center justify-center reaction"
>
<span>{{ emoji }}</span>
<div class="d-flex align-center justify-center">
<img class="emoji" :src="emoji" />
</div>
</v-overlay>
<v-img :src="data?.avatar"></v-img>
<v-img
:class="{ cursorPointer: enableMenu }"
:id="`mod-${data?.identity}`"
:src="data?.avatar"
></v-img>
</v-avatar>
<v-menu v-if="enableMenu" :activator="`#mod-${data?.identity}`">
<v-list>
<v-list-item
:title="$t('moderation.promote', { role: $t('role.cohost') })"
@click="$emit('moderate', this.data?.identity, 'cohost')"
></v-list-item>
<v-list-item
v-if="type !== 'speaker'"
:title="$t('moderation.promote', { role: $t('role.speaker') })"
@click="$emit('moderate', this.data?.identity, 'speaker')"
></v-list-item>
<v-list-item
v-else
:title="$t('moderation.demote')"
@click="$emit('moderate', this.data?.identity, 'demote')"
></v-list-item>
<v-list-item
:title="$t('moderation.kick')"
@click="$emit('moderate', this.data?.identity, 'kick')"
></v-list-item>
</v-list>
</v-menu>
<h4 :class="canSpeak ? 'mt-1' : 'mt-2'">
<v-icon
v-if="canSpeak && !preview"
@ -116,9 +151,11 @@ export default {
outline: 3px solid cornflowerblue;
}
.reaction span {
font-size: 2rem;
color: white;
text-align: center;
.reaction img {
height: 2rem;
}
.cursorPointer {
cursor: pointer;
}
</style>

Wyświetl plik

@ -14,9 +14,24 @@ share: "Share"
copy: "Copy"
copied: "Copied"
enterRoom: "Enter"
leaveRoom: "Leave"
closeRoom: "Close"
leaveRoom: "Leave but keep this room open"
closeRoom: "Close this room"
close: "Close"
emojiReaction: "Open emoji reaction picker"
micStatus:
mute: "Mute microphone"
unmute: "Unmute microphone"
retry: "Retry enabling microphone"
request: "Send speaker request"
roomOperation:
operation: "Leave or close"
leave: "Leave this room"
close: "Close this room"
openRequests: "Open list of speaker requests"
edit: "Edit room information"
requestOperation:
decline: "Decline this speaker request"
accept: "Accept this speaker request"
connecting: "Connecting"
server: "Your Mastodon instance"
addressRequired: "Enter your instance address"
@ -25,27 +40,33 @@ editRoom: "Room Edit"
comingFuture: "Coming with future update!"
processing: "Processing now...<br />Keep this window open!"
lostWarning: "Unsaved data will be lost if you leave the page, are you sure?"
cannotUndone: "This process cannot be undone. Are you sure?"
staticLink:
title: "Your Audon Link"
hint: "Other participants can join to your room via this personal link while you're hosting."
form:
title: "Title"
titleRequired: "Room title required"
description: "Description"
restriction: "Who can join"
cohosts: "Cohosts"
cohostCanAlwaysJoin: "Cohosts can join regardless of this setting."
cohosts: "CoHosts"
cohostCanAlwaysJoin: "CoHosts can join regardless of this setting."
schedule: "Schedule at"
advertise: "Allow the bot ({bot}) to advertise your room"
relationships:
everyone: "Everyone"
following: "Followed accounts only"
follower: "Followers-only"
knowing: "Followed accounts and/or followers"
following: "Followees only (Accounts you're following)"
follower: "Followers only (Accounts following you)"
knowing: "Followees and/or followers"
mutual: "Your mutuals"
private: "Cohosts only"
private: "CoHosts only"
shareRoomMessage: "Join my Audon room!\n{link}\n\nTitle: {title}"
roomReady:
header: "Your room is ready!"
message: "Your room \"{title}\" is now ready. Share the following URL with other participants."
timeout: "The room will be closed automatically if you don't enter within {minutes} minutes."
errors:
offline: "This user is not hosting now."
invalidAddress: "Invalid address"
serverNotFound: "Instance not found"
notFound: "{value} not found"
@ -77,6 +98,15 @@ speakRequest:
microphoneBlocked: "Your browser has blocked access to the microphone. Check permission settings of your device and browser."
closeRoomConfirm: "Are you sure you want to close this room?"
roomEvent:
closedByHost: "Host has closed this room."
closedByHost: "This room has been closed."
removed: "You have been requested to leave."
disconnected: "Disconnected from this room."
moderation:
promote: "Promote to {role}"
demote: "Demote to listener"
kick: "Kick out"
role:
host: "Host"
cohost: "CoHost"
speaker: "Speaker"
listener: "Listener"

Wyświetl plik

@ -14,9 +14,21 @@ share: "シェア"
copy: "コピー"
copied: "コピーしました"
enterRoom: "入室"
leaveRoom: "退室"
closeRoom: "閉室"
leaveRoom: "部屋を閉じずに退室"
closeRoom: "部屋を閉じる"
close: "閉じる"
emojiReaction: "絵文字ピッカーを開く"
micStatus:
mute: "マイクをミュート"
unmute: "マイクのミュートを解除"
retry: "マイク有効化を再試行"
request: "発言リクエストを送る"
roomOperation:
operation: "退室または閉室"
leave: "部屋から退室する"
close: "部屋を閉室する"
openRequests: "発言リクエストの一覧を開く"
edit: "部屋の情報を編集する"
connecting: "接続中"
server: "Mastodon サーバー"
addressRequired: "アドレスを入力してください"
@ -25,6 +37,10 @@ editRoom: "部屋の編集"
comingFuture: "今後のアップデートで追加予定"
processing: "処理中です。<br />画面を閉じないでください。"
lostWarning: "この画面を閉じると保存前の内容が失われます。構いませんか?"
cannotUndone: "この操作は取り消せません。続行しますか?"
staticLink:
title: "Audon リンク"
hint: "あなたが部屋をホストしたとき、他の人はこの固定 URL からでも参加できます。"
form:
title: "タイトル"
titleRequired: "部屋の名前を入力してください"
@ -45,7 +61,9 @@ shareRoomMessage: "Audon で部屋を作りました!\n参加用リンク {
roomReady:
header: "お部屋の用意ができました!"
message: "{title} を作りました。参加者に以下の URL を共有してください。"
timeout: "{minutes} 分以内に入室しないと部屋が閉じますのでご注意ください。"
errors:
offline: "このユーザーは現在ホスト中ではありません。"
invalidAddress: "アドレスが有効ではありません"
serverNotFound: "サーバーが見つかりません"
notFound: "{value} が見つかりません"
@ -77,6 +95,15 @@ speakRequest:
microphoneBlocked: "マイクが禁止されています。ブラウザやデバイスの設定からマイクの使用を許可してください。"
closeRoomConfirm: "この部屋を閉じますか?"
roomEvent:
closedByHost: "ホストにより部屋が閉じられました。"
closedByHost: "部屋が閉じられました。"
removed: "リクエストにより部屋から退去しました。"
disconneced: "切断されました。"
moderation:
promote: "{role} にする"
demote: "リスナー に戻す"
kick: "追い出す"
role:
host: "ホスト"
cohost: "共同ホスト"
speaker: "スピーカー"
listener: "リスナー"

Wyświetl plik

@ -1,7 +1,7 @@
import { createRouter, createWebHistory } from "vue-router";
import LoginView from "../views/LoginView.vue";
import RoomView from "../views/RoomView.vue";
import NotFoundView from "../views/NotFoundView.vue";
import ErrorView from "../views/ErrorView.vue";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -12,7 +12,15 @@ const router = createRouter({
meta: {
noauth: true,
},
component: NotFoundView,
component: ErrorView,
},
{
path: "/offline",
name: "offline",
meta: {
noauth: true,
},
component: ErrorView,
},
{
path: "/",

Wyświetl plik

@ -1,12 +0,0 @@
import { ref, computed } from "vue";
import { defineStore } from "pinia";
export const useCounterStore = defineStore("counter", () => {
const count = ref(0);
const doubleCount = computed(() => count.value * 2);
function increment() {
count.value++;
}
return { count, doubleCount, increment };
});

Wyświetl plik

@ -24,6 +24,13 @@ export const useMastodonStore = defineStore("mastodon", {
}
return "";
},
myStaticLink() {
if (this.oauth.audon?.webfinger) {
const url = new URL(location.href);
return `${url.origin}/u/@${this.oauth.audon.webfinger}`;
}
return "";
},
},
actions: {
async fetchToken() {
@ -40,16 +47,23 @@ export const useMastodonStore = defineStore("mastodon", {
this.authorized = true;
},
async updateAvatar(img, filename) {
return;
/*
if (this.client === null) return;
const avatarBlob = await (await fetch(img)).blob();
this.userinfo = await this.client.v1.accounts.updateCredentials({
avatar: new File([avatarBlob], `${Date.now()}_${filename}`),
});
*/
},
async revertAvatar() {
const token = await axios.get("/api/token");
const rooms = await axios.get("/api/room");
if (token.data.audon.avatar && rooms.data.length < 1) {
if (
token.data.audon.avatar &&
(rooms.data.length === 0 ||
(rooms.data.length === 1 && rooms.data[0].role === "host"))
) {
if (this.avatar) {
await this.updateAvatar(this.avatar, token.data.audon.avatar);
}

Wyświetl plik

@ -32,7 +32,15 @@ export default {
clipboard: useClipboard(),
};
},
created() {
async created() {
const resp = await axios.get("/api/room");
if (resp.data.length > 0) {
const canCreate = !some(resp.data, { role: "host" });
if (!canCreate) {
alert(this.$t("errors.alreadyAdded"));
this.$router.replace({ name: "home" });
}
}
this.cohostSearch = debounce(this.search, 1000);
},
unmounted() {
@ -52,7 +60,6 @@ export default {
{ title: this.$t("form.relationships.mutual"), value: "mutual" },
{ title: this.$t("form.relationships.private"), value: "private" },
],
scheduledAt: null,
searchResult: null,
searchQuery: "",
isCandiadateLoading: false,
@ -96,22 +103,24 @@ export default {
if (!donURL) return "";
const url = new URL(donURL);
const texts = [
this.$t("shareRoomMessage", { link: this.roomURL, title: this.title }),
this.$t("shareRoomMessage", {
link: this.donStore.myStaticLink,
title: this.title,
}),
];
if (this.description)
texts.push(truncate("\n" + this.description, { length: 200 }));
texts.push("\n#Audon");
return encodeURI(`${url.origin}/share?text=${texts.join("\n")}`);
},
},
watch: {
searchQuery(val) {
this.isCandiadateLoading = false;
this.searchError.enabled = false;
this.cohostSearch.cancel();
if (!val) return;
if (some(this.cohosts, { finger: val })) {
this.searchError.message = this.$t("errors.alreadyAdded");
this.searchError.colour = "warning";
this.searchError.enabled = true;
return;
}
@ -128,7 +137,12 @@ export default {
methods: {
async search(val) {
const finger = val.split("@");
if (finger.length !== 2) return;
if (finger.length < 2 || finger.length > 3) {
this.searchError.message = this.$t("errors.invalidAddress");
this.searchError.enabled = true;
this.isCandiadateLoading = false;
return;
}
try {
const resp = await this.donStore.client.v1.accounts.search({
q: val,
@ -141,7 +155,6 @@ export default {
this.searchError.enabled = false;
} catch (error) {
this.searchError.message = this.$t("errors.notFound", { value: val });
this.searchError.colour = "error";
this.searchError.enabled = true;
} finally {
this.isCandiadateLoading = false;
@ -166,8 +179,7 @@ export default {
title: this.title,
description: this.description,
cohosts: map(this.cohosts, (u) => ({
remote_id: u.id,
remote_url: u.url,
webfinger: webfinger(u),
})),
restriction: this.relationship,
advertise:
@ -180,7 +192,6 @@ export default {
const resp = await axios.post("/api/room", payload);
if (resp.status === 201) {
this.createdRoomID = resp.data;
// this.$router.push({ name: "room", params: { id: resp.data } });
}
} catch (error) {
this.searchError.message = `Error: ${error}`;
@ -201,7 +212,7 @@ export default {
{{ $t("roomReady.message", { title }) }}
</div>
<div class="my-3">
<h3 style="word-break: break-all">{{ roomURL }}</h3>
<h3 style="word-break: break-all">{{ donStore.myStaticLink }}</h3>
</div>
<div>
<v-btn
@ -213,7 +224,7 @@ export default {
>{{ $t("share") }}</v-btn
>
<v-btn
@click="clipboard.copy(roomURL)"
@click="clipboard.copy(donStore.myStaticLink)"
color="lime"
size="small"
:prepend-icon="
@ -222,7 +233,10 @@ export default {
>{{ clipboard.copied.value ? $t("copied") : $t("copy") }}</v-btn
>
</div>
<div class="text-center mt-10 mb-1">
<v-alert class="mt-5" density="compact" type="warning" variant="tonal">{{
$t("roomReady.timeout", { minutes: 5 })
}}</v-alert>
<div class="text-center mt-5 mb-1">
<v-btn
color="indigo"
:to="{ name: 'room', params: { id: createdRoomID } }"
@ -320,7 +334,7 @@ export default {
<v-list lines="two">
<v-list-item
:key="0"
:value="searchResult.acct"
:value="webfinger(searchResult)"
:title="searchResult.displayName"
@click="onResultClick"
>
@ -359,13 +373,6 @@ export default {
</v-text-field>
</v-card-actions>
</v-card>
<v-text-field
type="datetime-local"
v-model="scheduledAt"
:label="$t('form.schedule')"
disabled
:messages="[$t('comingFuture')]"
></v-text-field>
<v-checkbox
v-model="advertise"
:disabled="relationship !== 'everyone'"
@ -374,10 +381,8 @@ export default {
<template v-slot:label>
<i18n-t keypath="form.advertise" tag="div">
<template v-slot:bot>
<a
href="https://akkoma.audon.space/users/now"
target="_blank"
>now@audon.space</a
<a href="https://i.audon.space/@now" target="_blank"
>now@i.audon.space</a
>
</template>
</i18n-t>

Wyświetl plik

@ -0,0 +1,19 @@
<script>
export default {
data() {
return {
name: this.$route.name,
};
},
created() {
if (this.name === "offline") {
alert(this.$t("errors.offline"));
this.$router.push({ name: "home" });
}
},
};
</script>
<template>
<v-alert v-if="this.name === 'notfound'" type="error">Page not found</v-alert>
</template>

Wyświetl plik

@ -1,18 +1,28 @@
<script>
import { useMastodonStore } from "../stores/mastodon";
import axios from "axios";
import { some } from "lodash-es";
import { mdiLinkVariant } from "@mdi/js";
export default {
setup() {
return {
mdiLinkVariant,
donStore: useMastodonStore(),
};
},
data() {
return {
canCreate: true,
query: "",
};
},
async created() {
const resp = await axios.get("/api/room");
if (resp.data.length > 0) {
this.canCreate = !some(resp.data, { role: "host" });
}
},
methods: {
async onLogout() {
// if (!confirm(this.$t("logoutConfirm"))) return;
@ -41,7 +51,7 @@ export default {
{{ $t("logout") }}
</v-btn>
</div>
<div class="text-center my-10">
<div class="text-center my-8">
<v-avatar class="rounded" size="100">
<v-img
:src="donStore.userinfo?.avatar"
@ -63,10 +73,33 @@ export default {
<v-text-field v-mode="query"></v-text-field>
</v-col> -->
<v-col cols="12">
<v-btn block :to="{ name: 'create' }" color="indigo">{{
$t("createNewRoom")
}}</v-btn>
<v-btn
:disabled="!canCreate"
block
:to="{ name: 'create' }"
color="indigo"
>{{ $t("createNewRoom") }}</v-btn
>
</v-col>
</v-row>
<div class="d-flex justify-center mt-6">
<v-alert
:icon="mdiLinkVariant"
:title="$t('staticLink.title')"
variant="tonal"
>
<div class="my-1">
<h4 style="word-break: break-all">
<a
:href="donStore.myStaticLink"
@click.prevent=""
class="text-white"
>{{ donStore.myStaticLink }}</a
>
</h4>
</div>
<p>{{ $t("staticLink.hint") }}</p>
</v-alert>
</div>
</main>
</template>

Wyświetl plik

@ -88,7 +88,7 @@ export default {
alt="Branding Wordmark"
style="width: 100%; max-width: 200px"
/>
<p class="mt-2">Audio spaces for Mastodon</p>
<p class="mt-2">Audio space for Mastodon</p>
</div>
<v-alert v-if="$route.query.l" type="warning" variant="text">
<div>{{ $t("loginRequired") }}</div>

Wyświetl plik

@ -1,5 +0,0 @@
<script></script>
<template>
<v-alert type="error">Page not found</v-alert>
</template>

Wyświetl plik

@ -2,8 +2,8 @@
import axios from "axios";
import { pushNotFound, webfinger } from "../assets/utils";
import { useMastodonStore } from "../stores/mastodon";
import { map, some, omit, filter, trim, clone } from "lodash-es";
import { darkTheme } from "picmo";
import { map, some, omit, filter, trim, clone, differenceBy } from "lodash-es";
import { darkTheme, NativeRenderer } from "picmo";
import { createPopup } from "@picmo/popup-picker";
import { Howl } from "howler";
import Participant from "../components/Participant.vue";
@ -20,6 +20,8 @@ import {
mdiDotsVertical,
mdiPencil,
mdiEmoticon,
mdiCloseBoxOutline,
mdiExitRun,
} from "@mdi/js";
import {
Room,
@ -37,15 +39,6 @@ import boopSound from "../assets/boop.oga";
import messageSound from "../assets/message.oga";
import requestSound from "../assets/request.oga";
const publishOpts = {
audioBitrate: AudioPresets.music,
};
const captureOpts = {
autoGainControl: true,
echoCancellation: true,
};
export default {
setup() {
const noSleep = new NoSleep();
@ -61,6 +54,7 @@ export default {
webfinger,
clone,
noSleep,
mdiCloseBoxOutline,
mdiLogout,
mdiAccountVoice,
mdiMicrophone,
@ -72,6 +66,7 @@ export default {
mdiDotsVertical,
mdiPencil,
mdiEmoticon,
mdiExitRun,
v$: useVuelidate(),
donStore: useMastodonStore(),
decoder: new TextDecoder(),
@ -81,7 +76,7 @@ export default {
sounds: {
boop: new Howl({
src: [boopSound],
volume: 0.7,
volume: 0.5,
}),
message: new Howl({
src: [messageSound],
@ -89,7 +84,7 @@ export default {
}),
request: new Howl({
src: [requestSound],
volume: 0.7,
volume: 0.5,
}),
},
};
@ -118,7 +113,21 @@ export default {
return {
roomID: this.$route.params.id,
loading: false,
mainHeight: 700,
mainHeight: window.innerHeight - 120,
audioOptions: {
play: {
deviceId: 0,
},
capture: {
autoGainControl: false,
echoCancellation: true,
noiseSuppression: true,
},
publish: {
audioBitrate: AudioPresets.music,
forceStereo: false,
},
},
roomInfo: {
title: this.$t("connecting"),
description: "",
@ -153,6 +162,7 @@ export default {
showRequestDialog: false,
showRequestedNotification: false,
isEditLoading: false,
isRequestLoading: false,
closeLoading: false,
showEditDialog: false,
timeElapsed: "",
@ -161,7 +171,6 @@ export default {
},
async created() {
this.onResize();
// fetch mastodon token
if (!this.donStore.client || !this.donStore.authorized) {
try {
await this.donStore.fetchToken();
@ -224,10 +233,10 @@ export default {
return this.isHost(myAudonID);
},
iamCohost() {
const myInfo = this.donStore.userinfo;
const myInfo = this.donStore.oauth.audon;
if (!myInfo) return false;
return this.isCohost({ remote_id: myInfo.id, remote_url: myInfo.url });
return this.isCohost(myInfo);
},
iamSpeaker() {
const myAudonID = this.donStore.oauth.audon?.audon_id;
@ -236,7 +245,10 @@ export default {
return this.isSpeaker(myAudonID);
},
micStatusIcon() {
if (!this.micGranted) {
if (
!this.micGranted ||
!(this.iamHost || this.iamCohost || this.iamSpeaker)
) {
return mdiMicrophoneQuestion;
}
if (this.iamMuted) {
@ -244,11 +256,31 @@ export default {
}
return mdiMicrophone;
},
micStatusLabel() {
if (!(this.iamHost || this.iamCohost || this.iamSpeaker)) {
return this.$t("micStatus.request");
}
if (!this.micGranted) {
return this.$t("micStatus.retry");
}
if (this.iamMuted) {
return this.$t("micStatus.unmute");
}
return this.$t("micStatus.mute");
},
titleErrors() {
const errors = this.v$.editingRoomInfo.title.$errors;
const messages = map(errors, (e) => e.$message);
return messages;
},
isLastHost() {
return !some(
Object.values(this.participants),
(v) =>
v.audon_id !== this.donStore.oauth.audon?.audon_id &&
(this.isHost(v.audon_id) || this.isCohost(v))
);
},
},
methods: {
refreshTimeElapsed() {
@ -265,6 +297,7 @@ export default {
try {
this.loading = true;
await this.connectLivekit(token);
await this.roomClient.startAudio();
} catch (error) {
alert(this.$t("errors.connectionFailed"));
} finally {
@ -367,22 +400,51 @@ export default {
}
})
.on(RoomEvent.RoomMetadataChanged, (metadata) => {
self.roomInfo = JSON.parse(metadata);
const newRoominfo = JSON.parse(metadata);
const myAudonID = self.donStore.oauth.audon.audon_id;
const iamNewCohost = some(
differenceBy(
newRoominfo.cohosts,
self.roomInfo.cohosts,
"audon_id"
),
(v) => v.audon_id === myAudonID
);
if (iamNewCohost) {
self.closeLoading = true;
self.roomClient.disconnect();
self.donStore.revertAvatar().finally(() => {
window.location.reload();
});
}
const iamNewSpeaker = some(
differenceBy(
newRoominfo.speakers,
self.roomInfo.speakers,
"audon_id"
),
(v) => v.audon_id === myAudonID
);
self.roomInfo = newRoominfo;
self.editingRoomInfo = clone(self.roomInfo);
if (!self.roomInfo.speakers) return;
for (const speakers of self.roomInfo.speakers) {
self.speakRequests.delete(speakers.audon_id);
if (self.speakRequests.size < 1)
self.showRequestNotification = false;
}
if (self.iamSpeaker && !self.micGranted) {
if (self.iamSpeaker && iamNewSpeaker) {
self.roomClient.localParticipant
.setMicrophoneEnabled(true, captureOpts, publishOpts)
.setMicrophoneEnabled(
true,
self.audioOptions.capture,
self.audioOptions.publish
)
.then(() => {
self.micGranted = true;
})
.finally(() => {
self.roomClient.localParticipant.setMicrophoneEnabled(false);
self.mutedSpeakerIDs.add(myAudonID);
});
}
});
@ -407,8 +469,8 @@ export default {
try {
await this.roomClient.localParticipant.setMicrophoneEnabled(
true,
captureOpts,
publishOpts
this.audioOptions.capture,
this.audioOptions.publish
);
} catch {
alert(this.$t("microphoneBlocked"));
@ -428,19 +490,16 @@ export default {
}
},
onResize() {
const mainArea = document.getElementById("mainArea");
const height = mainArea.clientHeight;
this.mainHeight = height > 720 ? 700 : window.innerHeight - 120;
this.mainHeight = window.innerHeight - 120;
},
isHost(identity) {
return identity === this.roomInfo.host?.audon_id;
},
isCohost(metadata) {
isCohost(data) {
return (
metadata &&
data.webfinger &&
some(this.roomInfo.cohosts, {
remote_id: metadata.remote_id,
remote_url: metadata.remote_url,
webfinger: data.webfinger,
})
);
},
@ -461,13 +520,19 @@ export default {
this.sounds.request.play();
}
},
async onAcceptRequest(identity) {
// promote user to a speaker
// the livekit server will update room metadata
async onModerate(identity, op) {
if (!identity) return;
if (op === "kick" || op === "cohost") {
if (!confirm(this.$t("cannotUndone"))) {
return;
}
}
this.isRequestLoading = true;
try {
await axios.put(`/api/room/${this.roomID}/${identity}`);
} catch (reqError) {
console.log("permission update request error: ", reqError);
await axios.put(`/api/room/${this.roomID}`, { identity, op });
} finally {
this.isRequestLoading = false;
this.speakRequests.delete(identity);
}
},
async onDeclineRequest(identity) {
@ -491,6 +556,7 @@ export default {
emojiSize: "1.8rem",
autoFocus: "none",
showPreview: false,
renderer: new NativeRenderer(),
},
{
referenceElement: btn,
@ -500,8 +566,8 @@ export default {
}
);
const self = this;
picker.addEventListener("emoji:select", ({ emoji }) => {
self.onEmojiSelected(emoji);
picker.addEventListener("emoji:select", ({ url }) => {
self.onEmojiSelected(url);
});
this.emojiPicker = picker;
}
@ -557,19 +623,16 @@ export default {
return metadata;
},
async fetchMastoData(identity) {
if (
this.cachedMastoData[identity] !== undefined ||
this.roomInfo.accounts[identity] === undefined
)
return;
if (this.roomInfo.accounts[identity] === undefined) return;
try {
const resp = await axios.get(`/app/user/${identity}`);
const account = this.roomInfo.accounts[identity];
const info = {
acct: account.acct,
username: account.username,
displayName: account.displayName,
avatar: account.avatar,
url: account.url,
identity,
};
if (resp.data.avatar) {
info.avatar = `/storage/${resp.data.audon_id}/avatar/${resp.data.avatar}`;
@ -592,15 +655,15 @@ export default {
newMicStatus = true;
await this.roomClient.localParticipant.setMicrophoneEnabled(
newMicStatus,
captureOpts,
publishOpts
this.audioOptions.capture,
this.audioOptions.publish
);
} else if (myTrack) {
newMicStatus = myTrack.isMuted;
await this.roomClient.localParticipant.setMicrophoneEnabled(
newMicStatus,
captureOpts,
publishOpts
this.audioOptions.capture,
this.audioOptions.publish
);
}
if (newMicStatus) {
@ -721,7 +784,7 @@ export default {
@connect.once="joinRoom"
></JoinDialog>
<v-dialog v-model="showRequestDialog" max-width="500">
<v-card max-height="600" class="d-flex flex-column">
<v-card :loading="isRequestLoading" class="d-flex flex-column">
<v-card-title>{{ $t("speakRequest.label") }}</v-card-title>
<v-card-text class="flex-grow-1 overflow-auto py-0">
<v-list v-if="speakRequests.size > 0" lines="two" variant="tonal">
@ -743,13 +806,17 @@ export default {
size="small"
variant="text"
:icon="mdiCheck"
@click="onAcceptRequest(id)"
:disabled="isRequestLoading"
@click.once="onModerate(id, 'speaker')"
:aria-label="$t('requestOperation.accept')"
></v-btn>
<v-btn
size="small"
variant="text"
:icon="mdiClose"
:disabled="isRequestLoading"
@click="onDeclineRequest(id)"
:aria-label="$t('requestOperation.decline')"
></v-btn>
</template>
<v-list-item-subtitle>
@ -785,6 +852,7 @@ export default {
@click="showRequestedNotification = false"
:icon="mdiClose"
size="small"
:aria-label="$t('close')"
></v-btn>
</template>
</v-snackbar>
@ -809,6 +877,7 @@ export default {
@click="showRequestNotification = false"
:icon="mdiClose"
size="small"
:aria-label="$('close')"
></v-btn>
</template>
</v-snackbar>
@ -825,6 +894,7 @@ export default {
size="small"
variant="text"
color="white"
:aria-label="$t('roomOperation.edit')"
:icon="mdiPencil"
@click="showEditDialog = true"
></v-btn>
@ -869,6 +939,8 @@ export default {
:data="cachedMastoData[key]"
:muted="mutedSpeakerIDs.has(key)"
:emoji="emojiReactions[key]?.emoji"
@moderate="onModerate"
:enable-menu="iamHost || iamCohost"
>
</Participant>
</template>
@ -880,6 +952,8 @@ export default {
:data="cachedMastoData[key]"
type="listener"
:emoji="emojiReactions[key]?.emoji"
@moderate="onModerate"
:enable-menu="iamHost || iamCohost"
></Participant>
</template>
</v-row>
@ -897,6 +971,7 @@ export default {
<v-card-actions v-else class="justify-center" style="gap: 20px">
<v-btn
:icon="mdiEmoticon"
:aria-label="$t('emojiReaction')"
color="white"
variant="flat"
@click="onPickerPopup"
@ -905,23 +980,46 @@ export default {
</v-btn>
<v-btn
:icon="micStatusIcon"
:aria-label="micStatusLabel"
color="white"
variant="flat"
@click="onToggleMute"
></v-btn>
<v-btn
v-if="iamHost"
:icon="mdiLogout"
color="red"
:disabled="loading"
@click="onRoomClose"
variant="flat"
></v-btn>
<v-menu v-if="iamHost || iamCohost">
<template v-slot:activator="{ props }">
<v-btn
:icon="mdiLogout"
color="red"
:aria-label="$t('roomOperation.operation')"
:disabled="loading"
variant="flat"
v-bind="props"
></v-btn>
</template>
<v-list>
<v-list-item
:title="$t('closeRoom')"
:aria-label="$t('roomOperation.close')"
:prepend-icon="mdiCloseBoxOutline"
@click="onRoomClose"
class="text-red"
></v-list-item>
<v-list-item
:disabled="isLastHost"
:title="$t('leaveRoom')"
:aria-label="$t('roomOperation.leave')"
:prepend-icon="mdiExitRun"
@click="onLeave"
>
</v-list-item>
</v-list>
</v-menu>
<v-btn
v-else
:icon="mdiLogout"
color="red"
:disabled="loading"
:aria-label="$t('roomOperation.leave')"
@click="onLeave"
variant="flat"
></v-btn>
@ -933,6 +1031,7 @@ export default {
>
<v-btn
:icon="mdiAccountVoice"
:aria-label="$t('roomOperation.openRequests')"
variant="flat"
color="white"
@click="
@ -946,5 +1045,3 @@ export default {
</v-card>
</main>
</template>
<style scoped></style>

38
auth.go
Wyświetl plik

@ -8,6 +8,7 @@ import (
"strings"
"time"
"github.com/jellydator/ttlcache/v3"
"github.com/labstack/echo/v4"
mastodon "github.com/mattn/go-mastodon"
"github.com/oklog/ulid/v2"
@ -26,9 +27,12 @@ func verifyTokenInSession(c echo.Context) (bool, *mastodon.Account, error) {
}
acc, err := mastoClient.GetAccountCurrentUser(c.Request().Context())
acctUrl, _ := url.Parse(acc.URL)
finger := strings.Split(acc.Username, "@")
webfinger := fmt.Sprintf("%s@%s", finger[0], acctUrl.Host)
user, dbErr := findUserByID(c.Request().Context(), data.AudonID)
if err != nil || dbErr != nil || string(acc.ID) != user.RemoteID {
if err != nil || dbErr != nil || webfinger != user.Webfinger {
return false, nil, err
}
@ -62,7 +66,7 @@ func loginHandler(c echo.Context) (err error) {
req.Redirect = "/"
}
appConfig, err := getAppConfig(serverURL.String(), req.Redirect)
appConfig, err := getAppConfig(serverURL.String())
if err != nil {
return ErrInvalidRequestFormat
}
@ -85,15 +89,24 @@ func loginHandler(c echo.Context) (err error) {
return echo.NewHTTPError(http.StatusInternalServerError)
}
return c.String(http.StatusCreated, mastApp.AuthURI)
redirURL, err := url.Parse(mastApp.AuthURI)
if err != nil {
c.Logger().Warn(err)
return echo.NewHTTPError(http.StatusInternalServerError, "invalid_auth_uri")
}
q := redirURL.Query()
q.Add("state", req.Redirect)
redirURL.RawQuery = q.Encode()
return c.String(http.StatusCreated, redirURL.String())
}
return c.NoContent(http.StatusNoContent)
}
type OAuthRequest struct {
Code string `query:"code"`
Redirect string `query:"redir"`
Code string `query:"code"`
State string `query:"state"`
}
// handler for GET to /app/oauth?code=****
@ -118,7 +131,7 @@ func oauthHandler(c echo.Context) (err error) {
if err != nil {
return err
}
appConf, err := getAppConfig(data.MastodonConfig.Server, req.Redirect)
appConf, err := getAppConfig(data.MastodonConfig.Server)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
@ -138,7 +151,10 @@ func oauthHandler(c echo.Context) (err error) {
}
coll := mainDB.Collection(COLLECTION_USER)
if result, dbErr := findUserByRemote(c.Request().Context(), string(acc.ID), acc.URL); dbErr == mongo.ErrNoDocuments {
acctUrl, _ := url.Parse(acc.URL)
finger := strings.Split(acc.Username, "@")
webfinger := fmt.Sprintf("%s@%s", finger[0], acctUrl.Host)
if result, dbErr := findUserByWebfinger(c.Request().Context(), webfinger); dbErr == mongo.ErrNoDocuments {
entropy := ulid.Monotonic(rand.Reader, 0)
id, err := ulid.New(ulid.Timestamp(time.Now().UTC()), entropy)
if err != nil {
@ -146,13 +162,11 @@ func oauthHandler(c echo.Context) (err error) {
return echo.NewHTTPError(http.StatusInternalServerError)
}
data.AudonID = id.String()
acctUrl, _ := url.Parse(acc.URL)
finger := strings.Split(acc.Username, "@")
newUser := AudonUser{
AudonID: data.AudonID,
RemoteID: string(acc.ID),
RemoteURL: acc.URL,
Webfinger: fmt.Sprintf("%s@%s", finger[0], acctUrl.Host),
Webfinger: webfinger,
CreatedAt: time.Now().UTC(),
}
if _, insertErr := coll.InsertOne(c.Request().Context(), newUser); insertErr != nil {
@ -173,8 +187,7 @@ func oauthHandler(c echo.Context) (err error) {
return echo.NewHTTPError(http.StatusInternalServerError)
}
return c.Redirect(http.StatusFound, req.Redirect)
// return c.Redirect(http.StatusFound, "http://localhost:5173")
return c.Redirect(http.StatusFound, req.State)
}
func getUserTokenHandler(c echo.Context) (err error) {
@ -225,6 +238,7 @@ func authMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
data, err := getSessionData(c)
if err == nil && data.AudonID != "" {
if user, err := findUserByID(c.Request().Context(), data.AudonID); err == nil {
userSessionCache.Set(data.AudonID, data, ttlcache.DefaultTTL)
c.Set("user", user)
c.Set("data", data)
return next(c)

102
avatar.go
Wyświetl plik

@ -22,40 +22,18 @@ import (
"gopkg.in/gographics/imagick.v2/imagick"
)
func (u *AudonUser) GetIndicator(ctx context.Context, fnew []byte, room *Room) ([]byte, error) {
func (u *AudonUser) GetIndicator(ctx context.Context, fnew []byte, room *Room) (indicator []byte, original []byte, isGIF bool, err error) {
isGIF = false
if u == nil {
return nil, errors.New("nil user")
err = errors.New("nil user")
return
}
mtype := mimetype.Detect(fnew)
if !mimetype.EqualsAny(mtype.String(), "image/png", "image/jpeg", "image/webp", "image/gif") {
return nil, errors.New("file type not supported")
}
hash := sha256.Sum256(fnew)
var err error
// Check if user's original avatar exists
saved := u.GetOriginalAvatarPath(hash, mtype)
if _, err := os.Stat(saved); err != nil {
if err := os.MkdirAll(filepath.Dir(saved), 0775); err != nil {
return nil, err
}
// Write user's avatar if the original version doesn't exist
if err := os.WriteFile(saved, fnew, 0664); err != nil {
return nil, err
}
}
fname := filepath.Base(saved)
coll := mainDB.Collection(COLLECTION_USER)
if _, err = coll.UpdateOne(ctx,
bson.D{{Key: "audon_id", Value: u.AudonID}},
bson.D{
{Key: "$set", Value: bson.D{{Key: "avatar", Value: fname}}},
}); err != nil {
return nil, err
err = errors.New("file type not supported")
return
}
buf := bytes.NewReader(fnew)
@ -69,11 +47,58 @@ func (u *AudonUser) GetIndicator(ctx context.Context, fnew []byte, room *Room) (
newImg, err = webp.Decode(buf)
} else if mtype.Is("image/gif") {
newImg, err = gif.Decode(buf)
isGIF = true
}
if err != nil {
return nil, err
return
}
return u.createGIF(newImg, room.IsHost(u) || room.IsCoHost(u))
// encode to png to avoid recompression, except GIF
var origImg []byte
if !isGIF {
origBuf := new(bytes.Buffer)
if err = png.Encode(origBuf, newImg); err != nil {
return
}
origImg = origBuf.Bytes()
} else {
origImg = fnew
}
hash := sha256.Sum256(origImg)
// Check if user's original avatar exists
var filename string
if isGIF {
filename = fmt.Sprintf("%x.gif", hash)
} else {
filename = fmt.Sprintf("%x.png", hash)
}
saved := u.getAvatarImagePath(filename)
if _, err = os.Stat(saved); err != nil {
if err = os.MkdirAll(filepath.Dir(saved), 0775); err != nil {
return
}
// Write user's avatar if the original version doesn't exist
if err = os.WriteFile(saved, origImg, 0664); err != nil {
return
}
}
coll := mainDB.Collection(COLLECTION_USER)
if _, err = coll.UpdateOne(ctx,
bson.D{{Key: "audon_id", Value: u.AudonID}},
bson.D{
{Key: "$set", Value: bson.D{{Key: "avatar", Value: filename}}},
}); err != nil {
return
}
// indicator, err = u.createGIF(newImg, room.IsHost(u) || room.IsCoHost(u))
// if err != nil {
// return
// }
return indicator, origImg, isGIF, nil
}
func (u *AudonUser) createGIF(avatar image.Image, blue bool) ([]byte, error) {
@ -115,30 +140,25 @@ func (u *AudonUser) createGIF(avatar image.Image, blue bool) ([]byte, error) {
}
}
outBuf, _ := os.Create(u.GetWebPAvatarPath())
outBuf, _ := os.Create(u.getWebPAvatarPath())
defer outBuf.Close()
anim.Encode(outBuf)
imagick.Initialize()
defer imagick.Terminate()
if _, err := imagick.ConvertImageCommand([]string{"convert", u.GetWebPAvatarPath(), u.GetGIFAvatarPath()}); err != nil {
if _, err := imagick.ConvertImageCommand([]string{"convert", u.getWebPAvatarPath(), u.getGIFAvatarPath()}); err != nil {
return nil, err
}
return os.ReadFile(u.GetGIFAvatarPath())
return os.ReadFile(u.getGIFAvatarPath())
}
func (u *AudonUser) GetOriginalAvatarPath(hash [sha256.Size]byte, mtype *mimetype.MIME) string {
filename := fmt.Sprintf("%x%s", hash, mtype.Extension())
return u.getAvatarImagePath(filename)
}
func (u *AudonUser) GetGIFAvatarPath() string {
func (u *AudonUser) getGIFAvatarPath() string {
return u.getAvatarImagePath("indicator.gif")
}
func (u *AudonUser) GetWebPAvatarPath() string {
func (u *AudonUser) getWebPAvatarPath() string {
return u.getAvatarImagePath("indicator.webp")
}

Wyświetl plik

@ -6,6 +6,8 @@ import (
"net/url"
"os"
"path/filepath"
"strconv"
"time"
"github.com/joho/godotenv"
)
@ -30,11 +32,12 @@ type (
}
LivekitConfig struct {
APIKey string `validate:"required,ascii"`
APISecret string `validate:"required,ascii"`
Host string `validate:"required,hostname|hostname_port"`
LocalDomain string `validate:"required,hostname|hostname_port"`
URL *url.URL
APIKey string `validate:"required,ascii"`
APISecret string `validate:"required,ascii"`
Host string `validate:"required,hostname|hostname_port"`
LocalDomain string `validate:"required,hostname|hostname_port"`
URL *url.URL
EmptyRoomTimeout time.Duration `validate:"required"`
}
DBConfig struct {
@ -164,11 +167,16 @@ func loadConfig(envname string) (*AppConfig, error) {
appConf.Redis = redisConf
// Setup LiveKit config
timeout, err := strconv.Atoi(os.Getenv("LIVEKIT_EMPTY_ROOM_TIMEOUT"))
if err != nil {
return nil, err
}
lkConf := &LivekitConfig{
APIKey: os.Getenv("LIVEKIT_API_KEY"),
APISecret: os.Getenv("LIVEKIT_API_SECRET"),
Host: os.Getenv("LIVEKIT_HOST"),
LocalDomain: os.Getenv("LIVEKIT_LOCAL_DOMAIN"),
APIKey: os.Getenv("LIVEKIT_API_KEY"),
APISecret: os.Getenv("LIVEKIT_API_SECRET"),
Host: os.Getenv("LIVEKIT_HOST"),
LocalDomain: os.Getenv("LIVEKIT_LOCAL_DOMAIN"),
EmptyRoomTimeout: time.Duration(timeout) * time.Second,
}
if err := mainValidator.Struct(lkConf); err != nil {
return nil, err

4
go.mod
Wyświetl plik

@ -3,10 +3,12 @@ module audon
go 1.19
require (
github.com/gabriel-vasile/mimetype v1.4.1
github.com/go-playground/validator/v10 v10.11.1
github.com/go-redis/redis/v9 v9.0.0-rc.2
github.com/gorilla/sessions v1.2.1
github.com/jaevor/go-nanoid v1.3.0
github.com/jellydator/ttlcache/v3 v3.0.1
github.com/joho/godotenv v1.4.0
github.com/labstack/echo-contrib v0.13.0
github.com/labstack/echo/v4 v4.9.1
@ -33,7 +35,6 @@ require (
github.com/eapache/channels v1.1.0 // indirect
github.com/eapache/queue v1.1.0 // indirect
github.com/frostbyte73/go-throttle v0.0.0-20210621200530-8018c891361d // indirect
github.com/gabriel-vasile/mimetype v1.4.1 // indirect
github.com/go-logr/logr v1.2.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-logr/zapr v1.2.3 // indirect
@ -47,7 +48,6 @@ require (
github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/jellydator/ttlcache/v3 v3.0.1 // indirect
github.com/jxskiss/base62 v1.1.0 // indirect
github.com/klauspost/compress v1.13.6 // indirect
github.com/labstack/gommon v0.4.0 // indirect

286
room.go
Wyświetl plik

@ -12,7 +12,6 @@ import (
"os"
"time"
"github.com/gabriel-vasile/mimetype"
"github.com/jaevor/go-nanoid"
"github.com/jellydator/ttlcache/v3"
"github.com/labstack/echo/v4"
@ -35,42 +34,26 @@ func createRoomHandler(c echo.Context) error {
host := c.Get("user").(*AudonUser)
room.Host = host
// check if user is already hosting or cohosting
lkRooms, err := host.GetCurrentLivekitRooms(c.Request().Context())
if err != nil {
c.Logger().Error(err)
return echo.NewHTTPError(http.StatusInternalServerError)
}
for _, r := range lkRooms {
meta, err := getRoomMetadataFromLivekitRoom(r)
if err != nil {
c.Logger().Error(err)
return echo.NewHTTPError(http.StatusInternalServerError)
}
if meta.IsHost(host) || meta.IsCoHost(host) {
return ErrOperationNotPermitted
}
}
coll := mainDB.Collection(COLLECTION_ROOM)
now := time.Now().UTC()
if now.After(room.ScheduledAt) {
// host is trying to create an instant room even though there is another instant room that wasn't used, assumed that host won't use such rooms
if cur, err := coll.Find(c.Request().Context(),
bson.D{
{Key: "host.audon_id", Value: host.AudonID},
{Key: "ended_at", Value: time.Time{}}, // host didn't close
{Key: "$expr", Value: bson.D{ // instant room
{Key: "$eq", Value: bson.A{"$created_at", "$scheduled_at"}},
}},
}); err == nil {
defer cur.Close(c.Request().Context())
roomIDsToBeDeleted := []string{}
for cur.Next(c.Request().Context()) {
emptyRoom := new(Room)
if err := cur.Decode(emptyRoom); err == nil {
if !emptyRoom.IsAnyomeInLivekitRoom(c.Request().Context()) {
roomIDsToBeDeleted = append(roomIDsToBeDeleted, emptyRoom.RoomID)
}
}
}
if len(roomIDsToBeDeleted) > 0 {
coll.DeleteMany(c.Request().Context(), bson.D{{
Key: "room_id",
Value: bson.D{{Key: "$in", Value: roomIDsToBeDeleted}}},
})
}
}
room.ScheduledAt = now
} else {
// TODO: limit the number of rooms one can schedule?
}
// TODO: use a job scheduler to manage rooms?
@ -82,9 +65,11 @@ func createRoomHandler(c echo.Context) error {
}
room.RoomID = canonic()
room.CreatedAt = now
// if cohosts are already registered, retrieve their data from DB
for i, cohost := range room.CoHosts {
cohostUser, err := findUserByRemote(c.Request().Context(), cohost.RemoteID, cohost.RemoteURL)
cohostUser, err := findUserByWebfinger(c.Request().Context(), cohost.Webfinger)
if err == nil {
room.CoHosts[i] = cohostUser
}
@ -95,6 +80,37 @@ func createRoomHandler(c echo.Context) error {
return echo.NewHTTPError(http.StatusInternalServerError)
}
// Create livekit room
roomMetadata := &RoomMetadata{Room: room, Speakers: []*AudonUser{}, Kicked: []*AudonUser{}, MastodonAccounts: make(map[string]*MastodonAccount)}
metadata, _ := json.Marshal(roomMetadata)
_, err = lkRoomServiceClient.CreateRoom(c.Request().Context(), &livekit.CreateRoomRequest{
Name: room.RoomID,
Metadata: string(metadata),
})
if err != nil {
c.Logger().Error(err)
return echo.NewHTTPError(http.StatusConflict)
}
countdown := time.NewTimer(mainConfig.Livekit.EmptyRoomTimeout)
orphanRooms.Set(room.RoomID, true, ttlcache.DefaultTTL)
go func(r *Room, logger echo.Logger) {
<-countdown.C
if orphaned := orphanRooms.Get(r.RoomID); orphaned == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if !r.IsAnyomeInLivekitRoom(ctx) {
if err := endRoom(ctx, r); err != nil {
logger.Error(err)
}
}
}(room, c.Logger())
return c.String(http.StatusCreated, room.RoomID)
}
@ -236,23 +252,11 @@ func joinRoomHandler(c echo.Context) (err error) {
now := time.Now().UTC()
// check if room is not yet started
if room.ScheduledAt.After(now) {
return echo.NewHTTPError(http.StatusConflict, "not_yet_started")
}
// check if room has already ended
if !room.EndedAt.IsZero() && room.EndedAt.Before(now) {
return ErrAlreadyEnded
}
// return 403 if one has been kicked
for _, kicked := range room.Kicked {
if kicked.Equal(user) {
return echo.NewHTTPError(http.StatusForbidden)
}
}
canTalk := room.IsHost(user) || room.IsCoHost(user) // host and cohost can talk from the beginning
// check room restriction
@ -290,19 +294,24 @@ func joinRoomHandler(c echo.Context) (err error) {
}
}
roomMetadata := &RoomMetadata{Room: room, MastodonAccounts: make(map[string]*MastodonAccount)}
lkRoom, _ := getRoomInLivekit(c.Request().Context(), room.RoomID) // lkRoom will be nil if it doesn't exist
if lkRoom == nil {
return ErrRoomNotFound
}
roomMetadata, _ := getRoomMetadataFromLivekitRoom(lkRoom)
// return 403 if one has been kicked
for _, kicked := range roomMetadata.Kicked {
if kicked.Equal(user) {
return echo.NewHTTPError(http.StatusForbidden)
}
}
// Allows the user to talk if the user is a speaker
lkRoom, _ := getRoomInLivekit(c.Request().Context(), room.RoomID) // lkRoom will be nil if it doesn't exist
if lkRoom != nil {
if existingMetadata, _ := getRoomMetadataFromLivekitRoom(lkRoom); existingMetadata != nil {
roomMetadata = existingMetadata
for _, speaker := range existingMetadata.Speakers {
if speaker.AudonID == user.AudonID {
canTalk = true
break
}
}
for _, speaker := range roomMetadata.Speakers {
if speaker.AudonID == user.AudonID {
canTalk = true
break
}
}
@ -330,7 +339,7 @@ func joinRoomHandler(c echo.Context) (err error) {
if user.AvatarFile != "" {
orig, err := os.ReadFile(user.getAvatarImagePath(user.AvatarFile))
if err == nil && orig != nil {
resp.Original = fmt.Sprintf("data:%s;base64,%s", mimetype.Detect(orig), base64.StdEncoding.EncodeToString(orig))
resp.Original = fmt.Sprintf("data:image/png;base64,%s", base64.StdEncoding.EncodeToString(orig))
} else if orig == nil {
user.AvatarFile = ""
}
@ -374,66 +383,45 @@ func joinRoomHandler(c echo.Context) (err error) {
}
// Generate indicator GIF
indicator, err := user.GetIndicator(c.Request().Context(), fnew, room)
// indicator, original, isGIF, err := user.GetIndicator(c.Request().Context(), fnew, room)
_, original, isGIF, err := user.GetIndicator(c.Request().Context(), fnew, room)
origMime := "image/png"
if isGIF {
origMime = "image/gif"
}
if err != nil {
c.Logger().Error(err)
return echo.NewHTTPError(http.StatusInternalServerError)
}
resp.Original = fmt.Sprintf("data:%s;base64,%s", mimetype.Detect(fnew), base64.StdEncoding.EncodeToString(fnew))
resp.Indicator = fmt.Sprintf("data:image/gif;base64,%s", base64.StdEncoding.EncodeToString(indicator))
resp.Original = fmt.Sprintf("data:%s;base64,%s", origMime, base64.StdEncoding.EncodeToString(original))
// resp.Indicator = fmt.Sprintf("data:image/gif;base64,%s", base64.StdEncoding.EncodeToString(indicator))
} else if err != nil {
c.Logger().Error(err)
}
// Create room in LiveKit if it doesn't exist
if lkRoom == nil {
room.CreatedAt = now
coll := mainDB.Collection(COLLECTION_ROOM)
if _, err := coll.UpdateOne(c.Request().Context(),
bson.D{{Key: "room_id", Value: roomID}},
bson.D{{Key: "$set", Value: bson.D{{Key: "created_at", Value: now}}}}); err != nil {
c.Logger().Error(err)
return echo.NewHTTPError(http.StatusInternalServerError)
}
metadata, _ := json.Marshal(roomMetadata)
_, err = lkRoomServiceClient.CreateRoom(c.Request().Context(), &livekit.CreateRoomRequest{
Name: room.RoomID,
Metadata: string(metadata),
})
if err != nil {
c.Logger().Error(err)
return echo.NewHTTPError(http.StatusConflict)
}
} else {
currentMeta, err := getRoomMetadataFromLivekitRoom(lkRoom)
if err != nil {
c.Logger().Error(err)
return echo.NewHTTPError(http.StatusInternalServerError)
}
currentMeta.MastodonAccounts[user.AudonID] = mastoAccount
newMetadata, err := json.Marshal(currentMeta)
if err != nil {
c.Logger().Error(err)
return echo.NewHTTPError(http.StatusInternalServerError)
}
_, err = lkRoomServiceClient.UpdateRoomMetadata(c.Request().Context(), &livekit.UpdateRoomMetadataRequest{
Room: roomID,
Metadata: string(newMetadata),
})
if err != nil {
c.Logger().Error(err)
return echo.NewHTTPError(http.StatusInternalServerError)
}
// Update room metadata
roomMetadata.MastodonAccounts[user.AudonID] = mastoAccount
newMetadata, err := json.Marshal(roomMetadata)
if err != nil {
c.Logger().Error(err)
return echo.NewHTTPError(http.StatusInternalServerError)
}
_, err = lkRoomServiceClient.UpdateRoomMetadata(c.Request().Context(), &livekit.UpdateRoomMetadataRequest{
Room: roomID,
Metadata: string(newMetadata),
})
if err != nil {
c.Logger().Error(err)
return echo.NewHTTPError(http.StatusInternalServerError)
}
// Store user's session data in cache
data, _ := getSessionData(c)
roomSessionCache.Set(user.AudonID, data, ttlcache.DefaultTTL)
orphanRooms.Delete(roomID)
return c.JSON(http.StatusOK, resp)
}
// intended to be called by room's host
// intended to be called by room's host or cohost
func closeRoomHandler(c echo.Context) error {
roomID := c.Param("id")
if err := mainValidator.Var(&roomID, "required,printascii"); err != nil {
@ -448,15 +436,19 @@ func closeRoomHandler(c echo.Context) error {
c.Logger().Error(err)
return echo.NewHTTPError(http.StatusInternalServerError)
}
// return 410 if the room has already ended
if !room.EndedAt.IsZero() {
return ErrAlreadyEnded
}
// only host can close the room
meta := room.getRoomMetadata(c.Request().Context())
if meta == nil {
return ErrRoomNotFound
}
// only host or cohost can close the room
user := c.Get("user").(*AudonUser)
if !room.IsHost(user) {
if !meta.IsHost(user) && !meta.IsCoHost(user) {
return ErrOperationNotPermitted
}
@ -485,8 +477,8 @@ func leaveRoomHandler(c echo.Context) error {
return c.NoContent(http.StatusOK)
}
func updatePermissionHandler(c echo.Context) error {
roomID := c.Param("room")
func updateRoleHandler(c echo.Context) error {
roomID := c.Param("id")
// look up lkRoom in livekit
lkRoom, exists := getRoomInLivekit(c.Request().Context(), roomID)
@ -506,7 +498,12 @@ func updatePermissionHandler(c echo.Context) error {
return ErrOperationNotPermitted
}
audonID := c.Param("user")
params := make(map[string]string)
if err := c.Bind(&params); err != nil {
return ErrInvalidRequestFormat
}
audonID := params["identity"]
operation := params["op"]
if !lkRoomMetadata.IsUserInLivekitRoom(c.Request().Context(), audonID) {
return ErrUserNotFound
}
@ -521,17 +518,48 @@ func updatePermissionHandler(c echo.Context) error {
newPermission := &livekit.ParticipantPermission{
CanPublishData: true,
CanSubscribe: true,
CanPublish: true,
}
// promote user to a speaker
if c.Request().Method == http.MethodPut {
newPermission.CanPublish = true
if operation == "speaker" {
for _, speaker := range lkRoomMetadata.Speakers {
if speaker.Equal(tgtUser) {
return echo.NewHTTPError(http.StatusConflict, "already_speaking")
}
}
lkRoomMetadata.Speakers = append(lkRoomMetadata.Speakers, tgtUser)
} else if operation == "cohost" {
lkRoomMetadata.CoHosts = append(lkRoomMetadata.CoHosts, tgtUser)
coll := mainDB.Collection(COLLECTION_ROOM)
if _, err = coll.UpdateOne(c.Request().Context(),
bson.D{{Key: "room_id", Value: roomID}},
bson.D{{Key: "$set", Value: bson.D{{
Key: "cohosts",
Value: lkRoomMetadata.CoHosts,
}}}}); err != nil {
c.Logger().Error(err)
return echo.NewHTTPError(http.StatusInternalServerError)
}
} else if operation == "kick" {
lkRoomMetadata.Kicked = append(lkRoomMetadata.Kicked, tgtUser)
lkRoomServiceClient.RemoveParticipant(c.Request().Context(), &livekit.RoomParticipantIdentity{
Room: roomID,
Identity: tgtUser.AudonID,
})
} else if operation == "demote" {
newPermission.CanPublish = false
} else {
return ErrInvalidRequestFormat
}
if operation == "demote" || operation == "cohost" {
newSpeakers := make([]*AudonUser, 0, len(lkRoomMetadata.Speakers))
for _, v := range lkRoomMetadata.Speakers {
if v.AudonID != tgtUser.AudonID {
newSpeakers = append(newSpeakers, v)
}
}
lkRoomMetadata.Speakers = newSpeakers
}
newMetadata, err := json.Marshal(lkRoomMetadata)
@ -539,26 +567,28 @@ func updatePermissionHandler(c echo.Context) error {
c.Logger().Error(err)
return echo.NewHTTPError(http.StatusInternalServerError)
}
_, err = lkRoomServiceClient.UpdateRoomMetadata(c.Request().Context(), &livekit.UpdateRoomMetadataRequest{
Room: roomID,
Metadata: string(newMetadata),
})
if operation != "kick" {
_, err = lkRoomServiceClient.UpdateParticipant(c.Request().Context(), &livekit.UpdateParticipantRequest{
Room: roomID,
Identity: audonID,
Permission: newPermission,
})
if err != nil {
c.Logger().Error(err)
return echo.NewHTTPError(http.StatusInternalServerError)
}
}
metadataRequest := &livekit.UpdateRoomMetadataRequest{Room: roomID, Metadata: string(newMetadata)}
_, err = lkRoomServiceClient.UpdateRoomMetadata(context.Background(), metadataRequest)
if err != nil {
c.Logger().Error(err)
return echo.NewHTTPError(http.StatusInternalServerError)
}
info, err := lkRoomServiceClient.UpdateParticipant(c.Request().Context(), &livekit.UpdateParticipantRequest{
Room: roomID,
Identity: audonID,
Permission: newPermission,
})
if err != nil {
c.Logger().Error(err)
return echo.NewHTTPError(http.StatusInternalServerError)
}
return c.JSON(http.StatusOK, info)
return c.NoContent(http.StatusOK)
}
func getRoomToken(room *Room, user *AudonUser, canTalk bool) (string, error) {

Wyświetl plik

@ -31,6 +31,7 @@ type (
RoomMetadata struct {
*Room
Speakers []*AudonUser `json:"speakers"`
Kicked []*AudonUser `json:"kicked"`
MastodonAccounts map[string]*MastodonAccount `json:"accounts"`
}
@ -41,8 +42,6 @@ type (
Host *AudonUser `bson:"host" json:"host"`
CoHosts []*AudonUser `bson:"cohosts" json:"cohosts"`
Restriction JoinRestriction `bson:"restriction" json:"restriction"`
Kicked []*AudonUser `bson:"kicked" json:"kicked"`
ScheduledAt time.Time `bson:"scheduled_at" json:"scheduled_at"`
EndedAt time.Time `bson:"ended_at" json:"ended_at"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
Advertise string `bson:"advertise" json:"advertise"`
@ -133,6 +132,15 @@ func (r *Room) IsHost(u *AudonUser) bool {
return r != nil && r.Host.Equal(u)
}
func (r *RoomMetadata) IsSpeaker(u *AudonUser) bool {
for _, s := range r.Speakers {
if s.Equal(u) {
return true
}
}
return false
}
func getRoomMetadataFromLivekitRoom(lkRoom *livekit.Room) (*RoomMetadata, error) {
metadata := new(RoomMetadata)
if err := json.Unmarshal([]byte(lkRoom.GetMetadata()), metadata); err != nil {
@ -142,6 +150,18 @@ func getRoomMetadataFromLivekitRoom(lkRoom *livekit.Room) (*RoomMetadata, error)
return metadata, nil
}
func (r *Room) getRoomMetadata(ctx context.Context) *RoomMetadata {
lkRoom, _ := getRoomInLivekit(ctx, r.RoomID)
if lkRoom == nil {
return nil
}
meta, err := getRoomMetadataFromLivekitRoom(lkRoom)
if err != nil {
return nil
}
return meta
}
func (r *Room) ExistsInLivekit(ctx context.Context) bool {
lkRooms, _ := lkRoomServiceClient.ListRooms(ctx, &livekit.ListRoomsRequest{Names: []string{r.RoomID}})
@ -185,13 +205,8 @@ func createIndexes(ctx context.Context) error {
Options: options.Index().SetUnique(true),
},
{
Keys: bson.D{
{Key: "remote_url", Value: 1},
{Key: "remote_id", Value: 1},
},
},
{
Keys: bson.D{{Key: "webfinger", Value: 1}},
Keys: bson.D{{Key: "webfinger", Value: 1}},
Options: options.Index().SetUnique(true),
},
})
if err != nil {
@ -240,3 +255,12 @@ func findUserByID(ctx context.Context, audonID string) (*AudonUser, error) {
}
return &result, nil
}
func findUserByWebfinger(ctx context.Context, webfinger string) (*AudonUser, error) {
var result AudonUser
coll := mainDB.Collection(COLLECTION_USER)
if err := coll.FindOne(ctx, bson.D{{Key: "webfinger", Value: webfinger}}).Decode(&result); err != nil {
return nil, err
}
return &result, nil
}

Wyświetl plik

@ -53,8 +53,9 @@ var (
mainConfig *AppConfig
lkRoomServiceClient *lksdk.RoomServiceClient
localeBundle *i18n.Bundle
roomSessionCache *ttlcache.Cache[string, *SessionData]
userSessionCache *ttlcache.Cache[string, *SessionData]
webhookTimerCache *ttlcache.Cache[string, *time.Timer]
orphanRooms *ttlcache.Cache[string, bool]
)
func init() {
@ -154,10 +155,12 @@ func main() {
e.Use(session.Middleware(redisStore))
// Setup caches
roomSessionCache = ttlcache.New(ttlcache.WithTTL[string, *SessionData](24 * time.Hour))
webhookTimerCache = ttlcache.New(ttlcache.WithTTL[string, *time.Timer](60 * time.Second))
go roomSessionCache.Start()
userSessionCache = ttlcache.New(ttlcache.WithTTL[string, *SessionData](168 * time.Hour))
webhookTimerCache = ttlcache.New(ttlcache.WithTTL[string, *time.Timer](5 * time.Minute))
orphanRooms = ttlcache.New(ttlcache.WithTTL[string, bool](24 * time.Hour))
go userSessionCache.Start()
go webhookTimerCache.Start()
go orphanRooms.Start()
e.POST("/app/login", loginHandler)
e.GET("/app/oauth", oauthHandler)
@ -176,12 +179,13 @@ func main() {
api.POST("/room/:id", joinRoomHandler)
api.PATCH("/room/:id", updateRoomHandler)
api.DELETE("/room/:id", closeRoomHandler)
api.PUT("/room/:room/:user", updatePermissionHandler)
api.PUT("/room/:id", updateRoleHandler)
e.Static("/assets", "audon-fe/dist/assets")
e.Static("/static", "audon-fe/dist/static")
e.Static("/storage", mainConfig.StorageDir)
e.GET("/r/:id", renderRoomHandler)
e.GET("/u/:webfinger", redirectUserHandler)
e.GET("/*", func(c echo.Context) error {
return c.Render(http.StatusOK, "tmpl", &TemplateData{Config: &mainConfig.AppConfigBase})
})
@ -202,8 +206,9 @@ func main() {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
e.Logger.Print("Attempting graceful shutdown")
defer shutdownCancel()
roomSessionCache.DeleteAll()
userSessionCache.DeleteAll()
webhookTimerCache.DeleteAll()
orphanRooms.DeleteAll()
if err := e.Shutdown(shutdownCtx); err != nil {
e.Logger.Fatalf("Failed shutting down gracefully: %s\n", err.Error())
}
@ -220,25 +225,20 @@ func (cv *CustomValidator) Validate(i interface{}) error {
return nil
}
func getAppConfig(server string, redirPath string) (*mastodon.AppConfig, error) {
if redirPath == "" {
redirPath = "/"
}
func getAppConfig(server string) (*mastodon.AppConfig, error) {
redirectURI := "urn:ietf:wg:oauth:2.0:oob"
u := &url.URL{
Host: mainConfig.LocalDomain,
Scheme: "https",
Path: "/",
}
q := u.Query()
q.Add("redir", redirPath)
u.RawQuery = q.Encode()
u = u.JoinPath("app", "oauth")
redirectURI = u.String()
conf := &mastodon.AppConfig{
ClientName: "Audon",
Scopes: "read:accounts read:follows write:accounts",
ClientName: "Audon",
// Scopes: "read:accounts read:follows write:accounts",
Scopes: "read:accounts read:follows",
Website: "https://codeberg.org/nmkj/audon",
RedirectURIs: redirectURI,
}

107
user.go
Wyświetl plik

@ -2,12 +2,15 @@ package main
import (
"context"
"fmt"
"net/http"
"time"
"github.com/labstack/echo/v4"
"github.com/livekit/protocol/livekit"
mastodon "github.com/mattn/go-mastodon"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
type MastodonAccount struct {
@ -51,13 +54,62 @@ func getUserHandler(c echo.Context) error {
func getStatusHandler(c echo.Context) error {
u := c.Get("user").(*AudonUser)
ids, err := u.GetCurrentRoomIDs(c.Request().Context())
status, err := u.GetCurrentRoomStatus(c.Request().Context())
if err != nil {
c.Logger().Error(err)
return echo.NewHTTPError(http.StatusInternalServerError)
}
return c.JSON(http.StatusOK, ids)
return c.JSON(http.StatusOK, status)
}
func redirectUserHandler(c echo.Context) error {
input := c.Param("webfinger")
if err := mainValidator.Var(&input, "required,startswith=@,min=4"); err != nil {
return wrapValidationError(err)
}
webfinger := input[1:]
if err := mainValidator.Var(&webfinger, "email"); err != nil {
return wrapValidationError(err)
}
user, err := findUserByWebfinger(c.Request().Context(), webfinger)
if err != nil || user == nil {
return ErrUserNotFound
}
coll := mainDB.Collection(COLLECTION_ROOM)
opts := options.FindOne().SetSort(bson.D{{Key: "created_at", Value: -1}})
var room Room
searchCohost := false
if err := coll.FindOne(c.Request().Context(), bson.D{
{Key: "host.audon_id", Value: user.AudonID},
}, opts).Decode(&room); err == nil {
if room.ExistsInLivekit(c.Request().Context()) {
// redirect to the hosting room if online
return c.Redirect(http.StatusFound, fmt.Sprintf("/r/%s", room.RoomID))
} else {
searchCohost = true
}
} else {
searchCohost = true
}
if searchCohost {
// redirect to the first cohosting room if online
status, err := user.GetCurrentRoomStatus(c.Request().Context())
if err != nil {
c.Logger().Error(err)
return echo.NewHTTPError(http.StatusInternalServerError)
}
for _, v := range status {
if v.Role == "cohost" {
return c.Redirect(http.StatusFound, fmt.Sprintf("/r/%s", v.RoomID))
}
}
}
return c.Redirect(http.StatusFound, "/offline")
}
func (a *AudonUser) Equal(u *AudonUser) bool {
@ -65,7 +117,7 @@ func (a *AudonUser) Equal(u *AudonUser) bool {
return false
}
return a.AudonID == u.AudonID || (a.RemoteID == u.RemoteID && a.RemoteURL == u.RemoteURL)
return a.AudonID == u.AudonID || a.Webfinger == u.Webfinger
}
func (a *AudonUser) InLivekit(ctx context.Context) (bool, error) {
@ -90,14 +142,53 @@ func (a *AudonUser) ClearUserAvatar(ctx context.Context) error {
return err
}
func (a *AudonUser) GetCurrentRoomIDs(ctx context.Context) ([]string, error) {
type UserStatus struct {
RoomID string `json:"roomID"`
Role string `json:"role"`
}
func (a *AudonUser) GetCurrentRoomStatus(ctx context.Context) ([]UserStatus, error) {
rooms, err := a.GetCurrentLivekitRooms(ctx)
if err != nil {
return nil, err
}
roomIDs := make([]string, len(rooms))
for i, r := range rooms {
roomIDs[i] = r.GetName()
roomList := make([]UserStatus, len(rooms))
for _, r := range rooms {
meta, _ := getRoomMetadataFromLivekitRoom(r)
role := "listener"
if meta.Room.IsHost(a) {
role = "host"
} else if meta.Room.IsCoHost(a) {
role = "cohost"
} else if meta.IsSpeaker(a) {
role = "speaker"
}
roomList = append(roomList, UserStatus{
RoomID: r.GetName(),
Role: role,
})
}
return roomIDs, nil
for _, s := range roomList {
if s.Role == "host" {
return roomList, nil
}
}
allRooms, err := lkRoomServiceClient.ListRooms(ctx, &livekit.ListRoomsRequest{})
if err != nil {
return nil, err
}
for _, r := range allRooms.GetRooms() {
meta, _ := getRoomMetadataFromLivekitRoom(r)
if meta.IsHost(a) {
roomList = append(roomList, UserStatus{
RoomID: r.GetName(),
Role: "host",
})
}
}
return roomList, nil
}

Wyświetl plik

@ -1,19 +1,18 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/jellydator/ttlcache/v3"
"github.com/labstack/echo/v4"
"github.com/livekit/protocol/auth"
"github.com/livekit/protocol/webhook"
mastodon "github.com/mattn/go-mastodon"
"github.com/nicksnyder/go-i18n/v2/i18n"
"golang.org/x/net/context"
)
func livekitWebhookHandler(c echo.Context) error {
@ -38,7 +37,6 @@ func livekitWebhookHandler(c echo.Context) error {
}
}
} else if event.GetEvent() == webhook.EventParticipantLeft {
// Revert user's avatar
audonID := event.GetParticipant().GetIdentity()
user, err := findUserByID(c.Request().Context(), audonID)
if user == nil || err != nil {
@ -47,56 +45,17 @@ func livekitWebhookHandler(c echo.Context) error {
}
still, err := user.InLivekit(c.Request().Context())
if !still && err == nil {
data := roomSessionCache.Get(audonID)
data := userSessionCache.Get(audonID)
if data == nil {
return echo.NewHTTPError(http.StatusGone)
}
roomSessionCache.Delete(audonID)
mastoClient := getMastodonClient(data.Value())
if mastoClient == nil {
c.Logger().Errorf("unable to get mastodon client: %v", data.Value().MastodonConfig)
return echo.NewHTTPError(http.StatusInternalServerError)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
nextUser, err := findUserByID(ctx, audonID)
if err != nil {
log.Println(err)
}
cached := webhookTimerCache.Get(audonID)
if cached != nil {
oldTimer := cached.Value()
if !oldTimer.Stop() {
<-oldTimer.C
}
}
countdown := time.NewTimer(10 * time.Second)
webhookTimerCache.Set(audonID, countdown, ttlcache.DefaultTTL)
go func() {
<-countdown.C
webhookTimerCache.Delete(audonID)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
stillAgain, err := user.InLivekit(ctx)
if err != nil {
log.Println(err)
}
if stillAgain {
return
}
nextUser, err := findUserByID(ctx, audonID)
if err == nil && nextUser.AvatarFile != "" {
log.Printf("restoring avatar: %s\n", audonID)
if err != nil {
log.Println(err)
return
}
avatar := nextUser.getAvatarImagePath(nextUser.AvatarFile)
_, err = updateAvatar(ctx, mastoClient, avatar)
if err != nil {
log.Println(err)
}
nextUser.ClearUserAvatar(ctx)
} else if err != nil {
log.Println(err)
}
}()
nextUser.ClearUserAvatar(ctx)
}
} else if event.GetEvent() == webhook.EventRoomStarted {
// Have the bot advertise the room
@ -127,7 +86,7 @@ func livekitWebhookHandler(c echo.Context) error {
messages := []string{
header,
fmt.Sprintf(":udon: %s\n🎙 https://%s/r/%s", room.Title, mainConfig.LocalDomain, room.RoomID),
fmt.Sprintf(":udon: %s\n🎙 https://%s/u/@%s", room.Title, mainConfig.LocalDomain, room.Host.Webfinger),
}
if room.Description != "" {
messages = append(messages, room.Description)