kopia lustrzana https://codeberg.org/nmkj/audon
Porównaj commity
46 Commity
Autor | SHA1 | Data |
---|---|---|
Namekuji | 7adba191e6 | |
Namekuji | 3cc4ee4f84 | |
Namekuji | a606f7f4fb | |
Namekuji | 1bb384a10c | |
Namekuji | 0fc4e5fbec | |
Namekuji | 30cc7930fc | |
Namekuji | 6c4ef49085 | |
Namekuji | 85979a7ebf | |
Namekuji | 6f7b58f2e2 | |
Namekuji | 03e5cb4f02 | |
Namekuji | 87300ea112 | |
Namekuji | 7b837651dd | |
Namekuji | c40fb71147 | |
Namekuji | d52ae18c44 | |
Namekuji | 3138e2a596 | |
Namekuji | 80963c96e6 | |
Namekuji | 32dbfbc205 | |
Namekuji | e2a5fb6fd6 | |
Namekuji | f11d4f016a | |
Namekuji | fec8e75a74 | |
Namekuji | 150ad6df05 | |
Namekuji | db42f13ee4 | |
Namekuji | 8dc11e7f84 | |
Namekuji | 3ad0378bd2 | |
Namekuji | cff4f9eaab | |
Namekuji | 55ca35c3c8 | |
Namekuji | d4d1ab6792 | |
Namekuji | e1bbda00c8 | |
Namekuji | 06ed20cd98 | |
Namekuji | 2b70a85bca | |
Namekuji | 6088af7101 | |
Namekuji | a7f80e2ef0 | |
Namekuji | e28f2edb2e | |
Namekuji | 6adbf5de60 | |
Namekuji | b571b4df30 | |
Namekuji | 9543928276 | |
Namekuji | abfb2f62f4 | |
Namekuji | 7028f5834c | |
Namekuji | 8bfd860c24 | |
Namekuji | 63e2be37e2 | |
Namekuji | ca77984520 | |
Namekuji | 2047c3b691 | |
Namekuji | 1a6b838c9c | |
Namekuji | b21aa42aaa | |
Namekuji | 17e07d28a1 | |
Namekuji | 073510d4b5 |
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -20,7 +20,7 @@ webhook:
|
|||
|
||||
room:
|
||||
auto_create: false
|
||||
empty_timeout: 3600
|
||||
empty_timeout: 30
|
||||
max_participants: 0
|
||||
max_metadata_size: 0
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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/
|
||||
|
|
|
@ -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 \
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
Plik diff jest za duży
Load Diff
|
@ -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
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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}`;
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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: "リスナー"
|
||||
|
|
|
@ -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: "/",
|
||||
|
|
|
@ -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 };
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
<script></script>
|
||||
|
||||
<template>
|
||||
<v-alert type="error">Page not found</v-alert>
|
||||
</template>
|
|
@ -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
38
auth.go
|
@ -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
102
avatar.go
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
26
config.go
26
config.go
|
@ -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
4
go.mod
|
@ -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
286
room.go
|
@ -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(¶ms); 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) {
|
||||
|
|
42
schema.go
42
schema.go
|
@ -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
|
||||
}
|
||||
|
|
30
server.go
30
server.go
|
@ -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
107
user.go
|
@ -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
|
||||
}
|
||||
|
|
59
webhooks.go
59
webhooks.go
|
@ -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)
|
||||
|
|
Ładowanie…
Reference in New Issue