Compare commits
No commits in common. "375ae84ae33d352421dffcfbe44bbbdff32a3b11" and "f7b3faf8e89b240ca1e65eb698734039fcae9ce3" have entirely different histories.
375ae84ae3
...
f7b3faf8e8
11 changed files with 66 additions and 369 deletions
|
@ -77,9 +77,5 @@ func handleConfigOverrides(config *model.Config) error {
|
|||
if env, has := os.LookupEnv("ARIMELODY_DISCORD_CLIENT_ID"); has { config.Discord.ClientID = env }
|
||||
if env, has := os.LookupEnv("ARIMELODY_DISCORD_SECRET"); has { config.Discord.Secret = env }
|
||||
|
||||
if env, has := os.LookupEnv("ARIMELODY_TWITCH_BROADCASTER"); has { config.Twitch.Broadcaster = env }
|
||||
if env, has := os.LookupEnv("ARIMELODY_TWITCH_CLIENT_ID"); has { config.Twitch.ClientID = env }
|
||||
if env, has := os.LookupEnv("ARIMELODY_TWITCH_SECRET"); has { config.Twitch.Secret = env }
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -1,94 +0,0 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"arimelody-web/model"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const TWITCH_API_BASE = "https://api.twitch.tv/helix/"
|
||||
|
||||
func TwitchSetup(app *model.AppState) error {
|
||||
app.Twitch = &model.TwitchState{}
|
||||
err := RefreshTwitchToken(app)
|
||||
return err
|
||||
}
|
||||
|
||||
func RefreshTwitchToken(app *model.AppState) error {
|
||||
if app.Twitch != nil && app.Twitch.Token != nil && time.Now().UTC().After(app.Twitch.Token.ExpiresAt) {
|
||||
return nil
|
||||
}
|
||||
|
||||
requestUrl, _ := url.Parse("https://id.twitch.tv/oauth2/token")
|
||||
req, _ := http.NewRequest(http.MethodPost, requestUrl.String(), bytes.NewBuffer([]byte(url.Values{
|
||||
"client_id": []string{ app.Config.Twitch.ClientID },
|
||||
"client_secret": []string{ app.Config.Twitch.Secret },
|
||||
"grant_type": []string{ "client_credentials" },
|
||||
}.Encode())))
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type TwitchOAuthToken struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
oauthResponse := TwitchOAuthToken{}
|
||||
err = json.NewDecoder(res.Body).Decode(&oauthResponse)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
app.Twitch.Token = &model.TwitchOAuthToken{
|
||||
AccessToken: oauthResponse.AccessToken,
|
||||
ExpiresAt: time.Now().UTC().Add(time.Second * time.Duration(oauthResponse.ExpiresIn)).UTC(),
|
||||
TokenType: oauthResponse.TokenType,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var lastStreamState *model.TwitchStreamInfo
|
||||
var lastStreamStateAt time.Time
|
||||
|
||||
func GetTwitchStatus(app *model.AppState, broadcaster string) (*model.TwitchStreamInfo, error) {
|
||||
if lastStreamState != nil && time.Now().UTC().Before(lastStreamStateAt.Add(time.Minute)) {
|
||||
return lastStreamState, nil
|
||||
}
|
||||
|
||||
requestUrl, _ := url.Parse(TWITCH_API_BASE + "streams")
|
||||
requestUrl.RawQuery = url.Values{
|
||||
"user_login": []string{ broadcaster },
|
||||
}.Encode()
|
||||
req, _ := http.NewRequest(http.MethodGet, requestUrl.String(), nil)
|
||||
req.Header.Set("Client-Id", app.Config.Twitch.ClientID)
|
||||
req.Header.Set("Authorization", "Bearer " + app.Twitch.Token.AccessToken)
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type StreamsResponse struct {
|
||||
Data []model.TwitchStreamInfo `json:"data"`
|
||||
}
|
||||
streamInfo := StreamsResponse{}
|
||||
err = json.NewDecoder(res.Body).Decode(&streamInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(streamInfo.Data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
lastStreamState = &streamInfo.Data[0]
|
||||
lastStreamStateAt = time.Now().UTC()
|
||||
return lastStreamState, nil
|
||||
}
|
49
main.go
49
main.go
|
@ -22,6 +22,7 @@ import (
|
|||
"arimelody-web/cursor"
|
||||
"arimelody-web/log"
|
||||
"arimelody-web/model"
|
||||
"arimelody-web/templates"
|
||||
"arimelody-web/view"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
@ -39,7 +40,6 @@ func main() {
|
|||
|
||||
app := model.AppState{
|
||||
Config: controller.GetConfig(),
|
||||
Twitch: nil,
|
||||
}
|
||||
|
||||
// initialise database connection
|
||||
|
@ -460,13 +460,6 @@ func main() {
|
|||
// handle DB migrations
|
||||
controller.CheckDBVersionAndMigrate(app.DB)
|
||||
|
||||
if app.Config.Twitch != nil {
|
||||
err = controller.TwitchSetup(&app)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to set up Twitch integration: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// initial invite code
|
||||
accountsCount := 0
|
||||
err = app.DB.Get(&accountsCount, "SELECT count(*) FROM account")
|
||||
|
@ -518,13 +511,49 @@ func createServeMux(app *model.AppState) *http.ServeMux {
|
|||
mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(app)))
|
||||
mux.Handle("/api/", http.StripPrefix("/api", api.Handler(app)))
|
||||
mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(app)))
|
||||
mux.Handle("/uploads/", http.StripPrefix("/uploads", view.StaticHandler(filepath.Join(app.Config.DataDirectory, "uploads"))))
|
||||
mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler(filepath.Join(app.Config.DataDirectory, "uploads"))))
|
||||
mux.Handle("/cursor-ws", cursor.Handler(app))
|
||||
mux.Handle("/", view.IndexHandler(app))
|
||||
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodHead {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
||||
err := templates.IndexTemplate.Execute(w, nil)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
staticHandler("public").ServeHTTP(w, r)
|
||||
}))
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
func staticHandler(directory string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
info, err := os.Stat(filepath.Join(directory, filepath.Clean(r.URL.Path)))
|
||||
|
||||
// does the file exist?
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// is thjs a directory? (forbidden)
|
||||
if info.IsDir() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.FileServer(http.Dir(directory)).ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
var PoweredByStrings = []string{
|
||||
"nerd rage",
|
||||
"estrogen",
|
||||
|
|
|
@ -21,12 +21,6 @@ type (
|
|||
Secret string `toml:"secret"`
|
||||
}
|
||||
|
||||
TwitchConfig struct {
|
||||
Broadcaster string `toml:"broadcaster"`
|
||||
ClientID string `toml:"client_id"`
|
||||
Secret string `toml:"secret"`
|
||||
}
|
||||
|
||||
Config struct {
|
||||
BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."`
|
||||
Host string `toml:"host"`
|
||||
|
@ -34,14 +28,12 @@ type (
|
|||
DataDirectory string `toml:"data_dir"`
|
||||
TrustedProxies []string `toml:"trusted_proxies"`
|
||||
DB DBConfig `toml:"db"`
|
||||
Discord *DiscordConfig `toml:"discord"`
|
||||
Twitch *TwitchConfig `toml:"twitch"`
|
||||
Discord DiscordConfig `toml:"discord"`
|
||||
}
|
||||
|
||||
AppState struct {
|
||||
DB *sqlx.DB
|
||||
Config Config
|
||||
Log log.Logger
|
||||
Twitch *TwitchState
|
||||
}
|
||||
)
|
||||
|
|
|
@ -1,43 +0,0 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type (
|
||||
TwitchOAuthToken struct {
|
||||
AccessToken string
|
||||
ExpiresAt time.Time
|
||||
TokenType string
|
||||
}
|
||||
|
||||
TwitchState struct {
|
||||
Token *TwitchOAuthToken
|
||||
}
|
||||
|
||||
TwitchStreamInfo struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
UserLogin string `json:"user_login"`
|
||||
UserName string `json:"user_name"`
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
ViewerCount int `json:"viewer_count"`
|
||||
StartedAt string `json:"started_at"`
|
||||
Language string `json:"language"`
|
||||
ThumbnailURL string `json:"thumbnail_url"`
|
||||
TagIDs []string `json:"tag_ids"`
|
||||
Tags []string `json:"tags"`
|
||||
IsMature bool `json:"is_mature"`
|
||||
}
|
||||
)
|
||||
|
||||
func (info *TwitchStreamInfo) Thumbnail(width int, height int) string {
|
||||
res := strings.Replace(info.ThumbnailURL, "{width}", fmt.Sprintf("%d", width), 1)
|
||||
res = strings.Replace(res, "{height}", fmt.Sprintf("%d", height), 1)
|
||||
return res
|
||||
}
|
|
@ -6,7 +6,6 @@
|
|||
--secondary: #f8e05b;
|
||||
--tertiary: #f788fe;
|
||||
--links: #5eb2ff;
|
||||
--live: #fd3737;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
|
|
|
@ -222,113 +222,3 @@ div#web-buttons {
|
|||
transform: translate(-2px, -2px);
|
||||
box-shadow: 1px 1px 0 #eee, 2px 2px 0 #eee;
|
||||
}
|
||||
|
||||
#live-banner {
|
||||
margin: 1em 0 2em 0;
|
||||
padding: 1em;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--primary);
|
||||
box-shadow: 0 0 8px var(--primary);
|
||||
}
|
||||
|
||||
#live-banner p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.live-highlight {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.live-preview {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.live-preview div:first-of-type {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: .3em;
|
||||
}
|
||||
|
||||
.live-thumbnail {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.live-button {
|
||||
margin: .2em;
|
||||
padding: .4em .5em;
|
||||
display: inline-block;
|
||||
color: var(--primary);
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: 4px;
|
||||
transition: color .1s linear, background-color .1s linear, box-shadow .1s linear;
|
||||
}
|
||||
|
||||
.live-button:hover {
|
||||
color: var(--background);
|
||||
background-color: var(--primary);
|
||||
box-shadow: 0 0 8px var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.live-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .3em;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#live-banner h2 {
|
||||
margin: 0;
|
||||
color: var(--on-background);
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 800;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.live-pinger {
|
||||
width: .5em;
|
||||
height: .5em;
|
||||
margin: .1em .2em;
|
||||
display: inline-block;
|
||||
border-radius: 100%;
|
||||
background-color: var(--primary);
|
||||
box-shadow: 0 0 4px var(--primary);
|
||||
animation: live-pinger-pulse 1s infinite alternate ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes live-pinger-pulse {
|
||||
from {
|
||||
opacity: .8;
|
||||
transform: scale(1.0);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.live-game {
|
||||
overflow: hidden;
|
||||
text-wrap: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.live-game .live-game-prefix {
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
.live-title {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.live-viewers {
|
||||
opacity: .5;
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
@import url("/style/footer.css");
|
||||
@import url("/style/prideflag.css");
|
||||
@import url("/style/cursor.css");
|
||||
@import url("/font/inter/inter.css");
|
||||
|
||||
@font-face {
|
||||
font-family: "Monaspace Argon";
|
||||
|
|
|
@ -1,45 +0,0 @@
|
|||
package view
|
||||
|
||||
import (
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/model"
|
||||
"arimelody-web/templates"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func IndexHandler(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodHead {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
type IndexData struct {
|
||||
TwitchStatus *model.TwitchStreamInfo
|
||||
}
|
||||
|
||||
var err error
|
||||
var twitchStatus *model.TwitchStreamInfo = nil
|
||||
if app.Twitch != nil && len(app.Config.Twitch.Broadcaster) > 0 {
|
||||
twitchStatus, err = controller.GetTwitchStatus(app, app.Config.Twitch.Broadcaster)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to get Twitch status for %s: %v\n", app.Config.Twitch.Broadcaster, err)
|
||||
}
|
||||
}
|
||||
|
||||
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
||||
err := templates.IndexTemplate.Execute(w, IndexData{
|
||||
TwitchStatus: twitchStatus,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to render index page: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
StaticHandler("public").ServeHTTP(w, r)
|
||||
})
|
||||
}
|
|
@ -1,31 +0,0 @@
|
|||
package view
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func StaticHandler(directory string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
info, err := os.Stat(filepath.Join(directory, filepath.Clean(r.URL.Path)))
|
||||
|
||||
// does the file exist?
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// is thjs a directory? (forbidden)
|
||||
if info.IsDir() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.FileServer(http.Dir(directory)).ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
|
@ -6,8 +6,8 @@
|
|||
|
||||
<meta property="og:title" content="ari melody">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="www.arimelody.space">
|
||||
<meta property="og:image" content="https://www.arimelody.space/img/favicon.png">
|
||||
<meta property="og:url" content="www.arimelody.me">
|
||||
<meta property="og:image" content="https://www.arimelody.me/img/favicon.png">
|
||||
<meta property="og:site_name" content="ari melody">
|
||||
<meta property="og:description" content="home to your local SPACEGIRL 💫">
|
||||
|
||||
|
@ -22,23 +22,6 @@
|
|||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
{{if .TwitchStatus}}
|
||||
<div id="live-banner">
|
||||
<div class="live-preview">
|
||||
<div>
|
||||
<img src="{{.TwitchStatus.Thumbnail 144 81}}" alt="livestream thumbnail" class="live-thumbnail">
|
||||
<a href="https://twitch.tv/{{.TwitchStatus.UserName}}" class="live-button">join in!</a>
|
||||
</div>
|
||||
<div class="live-info">
|
||||
<h2>ari melody <span class="live-highlight">LIVE</span> <i class="live-pinger"></i></h2>
|
||||
<p class="live-game"><span class="live-game-prefix">streaming:</span> {{.TwitchStatus.GameName}}</p>
|
||||
<p class="live-title">{{.TwitchStatus.Title}}</p>
|
||||
<p class="live-viewers">{{.TwitchStatus.ViewerCount}} viewers</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<h1 class="typeout">
|
||||
# hello, world!
|
||||
</h1>
|
||||
|
@ -101,6 +84,28 @@
|
|||
<p>
|
||||
<strong>where to find me 🛰️</strong>
|
||||
</p>
|
||||
<!--
|
||||
<ul class="links">
|
||||
<li>
|
||||
<a href="https://youtube.com/@arispacegirl">youtube</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://twitch.tv/arispacegirl">twitch</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://arimelody.bandcamp.com">bandcamp</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://codeberg.org/arimelody">codeberg</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/arimelody.me">bluesky</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://discord.gg/MmJtBebF28">discord</a>
|
||||
</li>
|
||||
</ul>
|
||||
-->
|
||||
<ul class="platform-links">
|
||||
<li>
|
||||
<a href="https://youtube.com/@arispacegirl" title="youtube">
|
||||
|
@ -127,7 +132,7 @@
|
|||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/arimelody.space" title="bluesky">
|
||||
<a href="https://bsky.app/profile/arimelody.me" title="bluesky">
|
||||
<img src="/img/brand/bluesky.svg" alt="bluesky" width="32" height="32"/>
|
||||
bluesky
|
||||
</a>
|
||||
|
@ -159,7 +164,7 @@
|
|||
</div>
|
||||
</li>
|
||||
<li class="project-item">
|
||||
<img src="https://git.arimelody.me/repo-avatars/6b0a1ffb78cbc6f906f83152ea42a710220174e8f48a3e44f159ae58dacd7a2f" alt="pride flag icon" aria-hidden=true class="project-icon" width="64" height="64">
|
||||
<img src="https://git.arimelody.me/repo-avatars/6b0a1ffb78cbc6f906f83152ea42a710220174e8f48a3e44f159ae58dacd7a2f" alt="catdance icon" aria-hidden=true class="project-icon" width="64" height="64">
|
||||
<div class="project-info">
|
||||
<a href="https://git.arimelody.me/ari/prideflag">pride flag</a>
|
||||
<p>progressive pride flag widget for websites</p>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue