Merge pull request #2 from animenotifier/go

Catching up
This commit is contained in:
Allen Lydiard 2017-06-24 18:35:05 -03:00 committed by GitHub
commit facd70e094
53 changed files with 674 additions and 193 deletions

View File

@ -19,6 +19,11 @@ func init() {
return js return js
}) })
app.Get("/scripts.js", func(ctx *aero.Context) string {
ctx.SetResponseHeader("Content-Type", "application/javascript")
return js
})
// Web manifest // Web manifest
app.Get("/manifest.json", func(ctx *aero.Context) string { app.Get("/manifest.json", func(ctx *aero.Context) string {
return ctx.JSON(app.Config.Manifest) return ctx.JSON(app.Config.Manifest)

View File

@ -1,6 +1,7 @@
package auth package auth
import "github.com/aerogo/aero" import "github.com/aerogo/aero"
import "github.com/animenotifier/notify.moe/utils"
// Install ... // Install ...
func Install(app *aero.Application) { func Install(app *aero.Application) {
@ -10,6 +11,12 @@ func Install(app *aero.Application) {
// Logout // Logout
app.Get("/logout", func(ctx *aero.Context) string { app.Get("/logout", func(ctx *aero.Context) string {
if ctx.HasSession() { if ctx.HasSession() {
user := utils.GetUser(ctx)
if user != nil {
authLog.Info("User logged out", user.ID, ctx.RealIP(), user.Email, user.RealName())
}
ctx.Session().Set("userId", nil) ctx.Session().Set("userId", nil)
} }

View File

@ -8,6 +8,7 @@ import (
"github.com/aerogo/aero" "github.com/aerogo/aero"
"github.com/animenotifier/arn" "github.com/animenotifier/arn"
"github.com/animenotifier/notify.moe/utils"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"golang.org/x/oauth2/google" "golang.org/x/oauth2/google"
) )
@ -88,22 +89,86 @@ func InstallGoogleAuth(app *aero.Application) {
return ctx.Error(http.StatusBadRequest, "Failed parsing user data (JSON)", err) return ctx.Error(http.StatusBadRequest, "Failed parsing user data (JSON)", err)
} }
// Try to find an existing user by the Google user ID // Is this an existing user connecting another social account?
user, getErr := arn.GetUserFromTable("GoogleToUser", googleUser.Sub) user := utils.GetUser(ctx)
if user != nil {
println("Connected")
// Add GoogleToUser reference
err = user.ConnectGoogle(googleUser.Sub)
if err != nil {
ctx.Error(http.StatusInternalServerError, "Could not connect account to Google account", err)
}
return ctx.Redirect("/")
}
var getErr error
// Try to find an existing user via the Google user ID
user, getErr = arn.GetUserFromTable("GoogleToUser", googleUser.Sub)
if getErr == nil && user != nil { if getErr == nil && user != nil {
authLog.Info("User logged in via Google ID", user.ID, user.Nick, ctx.RealIP(), user.Email, user.RealName())
user.LastLogin = arn.DateTimeUTC()
user.Save()
session.Set("userId", user.ID) session.Set("userId", user.ID)
return ctx.Redirect("/") return ctx.Redirect("/")
} }
// Try to find an existing user by the associated e-mail address // Try to find an existing user via the associated e-mail address
user, getErr = arn.GetUserByEmail(googleUser.Email) user, getErr = arn.GetUserByEmail(googleUser.Email)
if getErr == nil && user != nil { if getErr == nil && user != nil {
authLog.Info("User logged in via Email", user.ID, user.Nick, ctx.RealIP(), user.Email, user.RealName())
user.LastLogin = arn.DateTimeUTC()
user.Save()
session.Set("userId", user.ID) session.Set("userId", user.ID)
return ctx.Redirect("/") return ctx.Redirect("/")
} }
return ctx.Error(http.StatusForbidden, "Account does not exist", getErr) // Register new user
user = arn.NewUser()
user.Nick = "g" + googleUser.Sub
user.Email = googleUser.Email
user.FirstName = googleUser.GivenName
user.LastName = googleUser.FamilyName
user.Gender = googleUser.Gender
user.LastLogin = arn.DateTimeUTC()
// Save basic user info already to avoid data inconsistency problems
user.Save()
// Register user
err = arn.RegisterUser(user)
if err != nil {
ctx.Error(http.StatusInternalServerError, "Could not register a new user", err)
}
// Connect account to a Google account
err = user.ConnectGoogle(googleUser.Sub)
if err != nil {
ctx.Error(http.StatusInternalServerError, "Could not connect account to Google account", err)
}
// Save user object again with updated data
user.Save()
// Login
session.Set("userId", user.ID)
// Log
authLog.Info("Registered new user", user.ID, user.Nick, ctx.RealIP(), user.Email, user.RealName())
// Redirect to frontpage
return ctx.Redirect("/")
}) })
} }

14
auth/log.go Normal file
View File

@ -0,0 +1,14 @@
package auth
import (
"os"
"github.com/aerogo/log"
)
var authLog = log.New()
func init() {
authLog.AddOutput(os.Stdout)
authLog.AddOutput(log.File("logs/auth.log"))
}

View File

@ -14,17 +14,15 @@
"layout", "layout",
"navigation", "navigation",
"headers", "headers",
"forms", "input",
"grid", "grid",
"colors",
"animelist",
"forum", "forum",
"settings",
"user", "user",
"video", "video",
"loading", "loading",
"fade", "fade",
"mobile" "mobile",
"extension"
], ],
"scripts": { "scripts": {
"main": "main" "main": "main"

View File

@ -24,7 +24,15 @@ func main() {
// Sort // Sort
sort.Slice(users, func(i, j int) bool { sort.Slice(users, func(i, j int) bool {
return users[i].Registered < users[j].Registered if users[i].LastSeen < users[j].LastSeen {
return false
}
if users[i].LastSeen > users[j].LastSeen {
return true
}
return users[i].Registered > users[j].Registered
}) })
// Add users to list // Add users to list

View File

@ -7,7 +7,6 @@ import (
"github.com/fatih/color" "github.com/fatih/color"
) )
const maxPopularAnime = 10 const maxPopularAnime = 10
// Note this is using the airing-anime as a template with modfications // Note this is using the airing-anime as a template with modfications

View File

@ -4,7 +4,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/aerogo/aero" "github.com/aerogo/flow"
"github.com/animenotifier/arn" "github.com/animenotifier/arn"
"github.com/fatih/color" "github.com/fatih/color"
) )
@ -12,7 +12,7 @@ import (
func main() { func main() {
color.Yellow("Updating search index") color.Yellow("Updating search index")
aero.Parallel(updateAnimeIndex, updateUserIndex) flow.Parallel(updateAnimeIndex, updateUserIndex)
color.Green("Finished.") color.Green("Finished.")
} }

View File

@ -9,5 +9,5 @@ import (
// Render layout. // Render layout.
func Render(ctx *aero.Context, content string) string { func Render(ctx *aero.Context, content string) string {
user := utils.GetUser(ctx) user := utils.GetUser(ctx)
return components.Layout(ctx.App, user, content) return components.Layout(ctx.App, ctx, user, content)
} }

View File

@ -1,4 +1,4 @@
component Layout(app *aero.Application, user *arn.User, content string) component Layout(app *aero.Application, ctx *aero.Context, user *arn.User, content string)
html(lang="en") html(lang="en")
head head
title= app.Config.Title title= app.Config.Title
@ -6,7 +6,7 @@ component Layout(app *aero.Application, user *arn.User, content string)
meta(name="theme-color", content=app.Config.Manifest.ThemeColor) meta(name="theme-color", content=app.Config.Manifest.ThemeColor)
link(rel="manifest", href="/manifest.json") link(rel="manifest", href="/manifest.json")
body body
#container #container(class=utils.GetContainerClass(ctx))
#header #header
Navigation(user) Navigation(user)
#content-container #content-container

View File

@ -14,6 +14,7 @@ import (
"github.com/animenotifier/notify.moe/pages/animelist" "github.com/animenotifier/notify.moe/pages/animelist"
"github.com/animenotifier/notify.moe/pages/animelistitem" "github.com/animenotifier/notify.moe/pages/animelistitem"
"github.com/animenotifier/notify.moe/pages/dashboard" "github.com/animenotifier/notify.moe/pages/dashboard"
"github.com/animenotifier/notify.moe/pages/embed"
"github.com/animenotifier/notify.moe/pages/forum" "github.com/animenotifier/notify.moe/pages/forum"
"github.com/animenotifier/notify.moe/pages/forums" "github.com/animenotifier/notify.moe/pages/forums"
"github.com/animenotifier/notify.moe/pages/login" "github.com/animenotifier/notify.moe/pages/login"
@ -26,7 +27,6 @@ import (
"github.com/animenotifier/notify.moe/pages/user" "github.com/animenotifier/notify.moe/pages/user"
"github.com/animenotifier/notify.moe/pages/users" "github.com/animenotifier/notify.moe/pages/users"
"github.com/animenotifier/notify.moe/pages/webdev" "github.com/animenotifier/notify.moe/pages/webdev"
"github.com/animenotifier/notify.moe/utils"
) )
var app = aero.New() var app = aero.New()
@ -71,19 +71,21 @@ func configure(app *aero.Application) *aero.Application {
app.Ajax("/login", login.Get) app.Ajax("/login", login.Get)
app.Ajax("/airing", airing.Get) app.Ajax("/airing", airing.Get)
app.Ajax("/webdev", webdev.Get) app.Ajax("/webdev", webdev.Get)
app.Ajax("/extension/embed", embed.Get)
// app.Ajax("/genres", genres.Get) // app.Ajax("/genres", genres.Get)
// app.Ajax("/genres/:name", genre.Get) // app.Ajax("/genres/:name", genre.Get)
// Middleware // Middleware
app.Use(middleware.Log()) app.Use(middleware.Log())
app.Use(middleware.Session()) app.Use(middleware.Session())
app.Use(middleware.UserInfo())
// API // API
api := api.New("/api/", arn.DB) api := api.New("/api/", arn.DB)
api.Install(app) api.Install(app)
// Domain // Domain
if utils.IsDevelopment() { if arn.IsDevelopment() {
app.Config.Domain = "beta.notify.moe" app.Config.Domain = "beta.notify.moe"
} }

43
middleware/IPToHost.go Normal file
View File

@ -0,0 +1,43 @@
package middleware
import (
"net"
"time"
cache "github.com/patrickmn/go-cache"
)
var ipToHosts = cache.New(60*time.Minute, 30*time.Minute)
// GetHostsForIP returns all host names for the given IP (if cached).
func GetHostsForIP(ip string) []string {
hosts, found := ipToHosts.Get(ip)
if !found {
hosts = findHostsForIP(ip)
}
if hosts == nil {
return nil
}
return hosts.([]string)
}
// Finds all host names for the given IP
func findHostsForIP(ip string) []string {
hosts, err := net.LookupAddr(ip)
if err != nil {
return nil
}
if len(hosts) == 0 {
return nil
}
// Cache host names
ipToHosts.Set(ip, hosts, cache.DefaultExpiration)
return hosts
}

View File

@ -9,26 +9,53 @@ import (
"github.com/aerogo/aero" "github.com/aerogo/aero"
"github.com/aerogo/log" "github.com/aerogo/log"
"github.com/animenotifier/notify.moe/utils"
) )
var request = log.New()
var err = log.New()
// Initialize log files
func init() {
request.AddOutput(log.File("logs/request.log"))
err.AddOutput(log.File("logs/error.log"))
err.AddOutput(os.Stderr)
}
// Log middleware logs every request into logs/request.log and errors into logs/error.log. // Log middleware logs every request into logs/request.log and errors into logs/error.log.
func Log() aero.Middleware { func Log() aero.Middleware {
request := log.New()
request.AddOutput(log.File("logs/request.log"))
err := log.New()
err.AddOutput(log.File("logs/error.log"))
err.AddOutput(os.Stderr)
return func(ctx *aero.Context, next func()) { return func(ctx *aero.Context, next func()) {
start := time.Now() start := time.Now()
next() next()
responseTime := time.Since(start) responseTime := time.Since(start)
go logRequest(ctx, responseTime)
}
}
// Logs a single request
func logRequest(ctx *aero.Context, responseTime time.Duration) {
responseTimeString := strconv.Itoa(int(responseTime.Nanoseconds()/1000000)) + " ms" responseTimeString := strconv.Itoa(int(responseTime.Nanoseconds()/1000000)) + " ms"
responseTimeString = strings.Repeat(" ", 8-len(responseTimeString)) + responseTimeString responseTimeString = strings.Repeat(" ", 8-len(responseTimeString)) + responseTimeString
user := utils.GetUser(ctx)
ip := ctx.RealIP()
hostName := "<unknown host>"
hostNames := GetHostsForIP(ip)
if len(hostNames) != 0 {
hostName = hostNames[0]
hostName = strings.TrimSuffix(hostName, ".")
}
// Log every request // Log every request
request.Info(ctx.RealIP(), ctx.StatusCode, responseTimeString, ctx.URI()) if user != nil {
request.Info(user.Nick, ip, hostName, responseTimeString, ctx.StatusCode, ctx.URI())
} else {
request.Info("[guest]", ip, hostName, responseTimeString, ctx.StatusCode, ctx.URI())
}
// Log all requests that failed // Log all requests that failed
switch ctx.StatusCode { switch ctx.StatusCode {
@ -36,13 +63,12 @@ func Log() aero.Middleware {
// Ok. // Ok.
default: default:
err.Error(http.StatusText(ctx.StatusCode), ctx.RealIP(), ctx.StatusCode, responseTimeString, ctx.URI()) err.Error(http.StatusText(ctx.StatusCode), ip, hostName, responseTimeString, ctx.StatusCode, ctx.URI())
} }
// Notify us about long requests. // Notify us about long requests.
// However ignore requests under /auth/ because those depend on 3rd party servers. // However ignore requests under /auth/ because those depend on 3rd party servers.
if responseTime >= 200*time.Millisecond && !strings.HasPrefix(ctx.URI(), "/auth/") { if responseTime >= 200*time.Millisecond && !strings.HasPrefix(ctx.URI(), "/auth/") {
err.Error("Long response time", ctx.RealIP(), ctx.StatusCode, responseTimeString, ctx.URI()) err.Error("Long response time", ip, hostName, responseTimeString, ctx.StatusCode, ctx.URI())
}
} }
} }

114
middleware/UserInfo.go Normal file
View File

@ -0,0 +1,114 @@
package middleware
import (
"encoding/json"
"io/ioutil"
"net/http"
"strconv"
"strings"
"github.com/aerogo/aero"
"github.com/animenotifier/arn"
"github.com/animenotifier/notify.moe/utils"
"github.com/fatih/color"
"github.com/mssola/user_agent"
"github.com/parnurzeal/gorequest"
)
var apiKeys arn.APIKeys
func init() {
data, _ := ioutil.ReadFile("security/api-keys.json")
err := json.Unmarshal(data, &apiKeys)
if err != nil {
panic(err)
}
}
// UserInfo updates user related information after each request.
func UserInfo() aero.Middleware {
return func(ctx *aero.Context, next func()) {
next()
// Ignore non-HTML requests
if ctx.IsMediaResponse() {
return
}
// Ignore API requests
if strings.HasPrefix(ctx.URI(), "/api/") {
return
}
user := utils.GetUser(ctx)
// When there's no user logged in, nothing to update
if user == nil {
return
}
// This works asynchronously so it doesn't block the response
go updateUserInfo(ctx, user)
}
}
// Update browser and OS data
func updateUserInfo(ctx *aero.Context, user *arn.User) {
newIP := ctx.RealIP()
newUserAgent := ctx.UserAgent()
if user.UserAgent != newUserAgent {
user.UserAgent = newUserAgent
// Parse user agent
parsed := user_agent.New(user.UserAgent)
// Browser
user.Browser.Name, user.Browser.Version = parsed.Browser()
// OS
os := parsed.OSInfo()
user.OS.Name = os.Name
user.OS.Version = os.Version
}
if user.IP != newIP {
updateUserLocation(user, newIP)
}
user.LastSeen = arn.DateTimeUTC()
user.Save()
}
// Updates the location of the user.
func updateUserLocation(user *arn.User, newIP string) {
user.IP = newIP
locationAPI := "https://api.ipinfodb.com/v3/ip-city/?key=" + apiKeys.IPInfoDB.ID + "&ip=" + user.IP + "&format=json"
response, data, err := gorequest.New().Get(locationAPI).EndBytes()
if len(err) > 0 && err[0] != nil {
color.Red("Couldn't fetch location data | Error: %s | IP: %s", err[0].Error(), user.IP)
return
}
if response.StatusCode != http.StatusOK {
color.Red("Couldn't fetch location data | Status: %d | IP: %s", response.StatusCode, user.IP)
return
}
newLocation := arn.IPInfoDBLocation{}
json.Unmarshal(data, &newLocation)
if newLocation.CountryName != "-" {
user.Location.CountryName = newLocation.CountryName
user.Location.CountryCode = newLocation.CountryCode
user.Location.Latitude, _ = strconv.ParseFloat(newLocation.Latitude, 64)
user.Location.Longitude, _ = strconv.ParseFloat(newLocation.Longitude, 64)
user.Location.CityName = newLocation.CityName
user.Location.RegionName = newLocation.RegionName
user.Location.TimeZone = newLocation.TimeZone
user.Location.ZipCode = newLocation.ZipCode
}
}

View File

@ -2,4 +2,4 @@ component AnimeGrid(animeList []*arn.Anime)
.anime-grid .anime-grid
each anime in animeList each anime in animeList
a.anime-grid-cell.ajax(href="/anime/" + toString(anime.ID)) a.anime-grid-cell.ajax(href="/anime/" + toString(anime.ID))
img.anime-grid-image(src=anime.Image.Small, alt=anime.Title.Romaji, title=anime.Title.Romaji + " (" + toString(anime.Rating.Overall) + ")") img.anime-grid-image.lazy(data-src=anime.Image.Small, alt=anime.Title.Romaji, title=anime.Title.Romaji + " (" + toString(anime.Rating.Overall) + ")")

View File

@ -4,7 +4,7 @@ component Avatar(user *arn.User)
component AvatarNoLink(user *arn.User) component AvatarNoLink(user *arn.User)
if user.HasAvatar() if user.HasAvatar()
img.user-image(src=user.SmallAvatar(), alt=user.Nick) img.user-image.lazy(data-src=user.SmallAvatar(), alt=user.Nick)
else else
SVGAvatar SVGAvatar

View File

@ -1,14 +1,19 @@
component InputText(id string, value string, label string, placeholder string) component InputText(id string, value string, label string, placeholder string)
.widget-input .widget-input
label(for=id)= label + ":" label(for=id)= label + ":"
input.widget-element.action(id=id, type="text", value=value, placeholder=placeholder, data-action="save", data-trigger="change") input.widget-element.action(id=id, type="text", value=value, placeholder=placeholder, title=placeholder, data-action="save", data-trigger="change")
component InputTextArea(id string, value string, label string, placeholder string) component InputTextArea(id string, value string, label string, placeholder string)
.widget-input .widget-input
label(for=id)= label + ":" label(for=id)= label + ":"
textarea.widget-element.action(id=id, placeholder=placeholder, data-action="save", data-trigger="change")= value textarea.widget-element.action(id=id, placeholder=placeholder, title=placeholder, data-action="save", data-trigger="change")= value
component InputNumber(id string, value int, label string, placeholder string, min string, max string) component InputNumber(id string, value int, label string, placeholder string, min string, max string)
.widget-input .widget-input
label(for=id)= label + ":" label(for=id)= label + ":"
input.widget-element.action(id=id, type="number", value=value, min=min, max=max, placeholder=placeholder, data-action="save", data-trigger="change") input.widget-element.action(id=id, type="number", value=value, min=min, max=max, placeholder=placeholder, title=placeholder, data-action="save", data-trigger="change")
component InputSelection(id string, value string, label string, placeholder string)
.widget-input
label(for=id)= label + ":"
select.widget-element.action(id=id, value=value, title=placeholder, data-action="save", data-trigger="change")

View File

@ -39,7 +39,7 @@ component LoggedInMenu(user *arn.User)
NavigationButtonNoAJAX("Logout", "/logout", "sign-out") NavigationButtonNoAJAX("Logout", "/logout", "sign-out")
component FuzzySearch component FuzzySearch
input#search.action(data-action="search", data-trigger="input", type="text", placeholder="Search...", title="Shortcut: Ctrl + Q") input#search.action(data-action="search", data-trigger="input", type="text", placeholder="Search...", title="Shortcut: F")
component NavigationButton(name string, target string, icon string) component NavigationButton(name string, target string, icon string)
a.navigation-link.ajax(href=target, aria-label=name, title=name) a.navigation-link.ajax(href=target, aria-label=name, title=name)

View File

@ -159,8 +159,11 @@ component Anime(anime *arn.Anime, user *arn.User)
//- if providers.AnimePlanet //- if providers.AnimePlanet
//- a.light-button(href="http://www.anime-planet.com/anime/" + providers.AnimePlanet.providerId, target="_blank") AnimePlanet //- a.light-button(href="http://www.anime-planet.com/anime/" + providers.AnimePlanet.providerId, target="_blank") AnimePlanet
.sources .footer
p Powered by Kitsu. //- if user != nil && user.Role == "admin"
//- a(href="/api/anime/" + anime.ID) Anime API
//- span |
span Powered by Kitsu.
//- if descriptionSource //- if descriptionSource
//- span= " Summary by " + summarySource + "." //- span= " Summary by " + summarySource + "."
//- //- //- //-

View File

@ -87,9 +87,9 @@
.anime-rating-categories .anime-rating-categories
vertical vertical
.sources .footer
font-size 0.8rem font-size 0.8rem
opacity 0.5 opacity 0.7
margin-top 0.5rem margin-top 0.5rem
.relations .relations

View File

@ -21,7 +21,7 @@ func Get(ctx *aero.Context) string {
animeList := viewUser.AnimeList() animeList := viewUser.AnimeList()
if animeList == nil { if animeList == nil {
return ctx.Error(http.StatusNotFound, "Anime list not found", err) return ctx.Error(http.StatusNotFound, "Anime list not found", nil)
} }
sort.Slice(animeList.Items, func(i, j int) bool { sort.Slice(animeList.Items, func(i, j int) bool {

View File

@ -10,5 +10,8 @@ component AnimeList(animeList *arn.AnimeList)
tr.anime-list-item.mountable(title=item.Notes) tr.anime-list-item.mountable(title=item.Notes)
td.anime-list-item-name td.anime-list-item-name
a.ajax(href=item.Anime().Link())= item.Anime().Title.Canonical a.ajax(href=item.Anime().Link())= item.Anime().Title.Canonical
td.anime-list-item-episodes= toString(item.Episodes) + " / " + item.Anime().EpisodeCountString() td.anime-list-item-episodes
span.anime-list-item-episodes-watched= item.Episodes
span.anime-list-item-episodes-separator /
span.anime-list-item-episodes-max= item.Anime().EpisodeCountString()
td.anime-list-item-rating= item.FinalRating() td.anime-list-item-rating= item.FinalRating()

View File

@ -7,6 +7,9 @@
.anime-list-item-name .anime-list-item-name
flex 0.8 flex 0.8
white-space nowrap
text-overflow ellipsis
overflow hidden
.anime-list-item-episodes .anime-list-item-episodes
flex 0.1 flex 0.1

31
pages/embed/embed.go Normal file
View File

@ -0,0 +1,31 @@
package embed
import (
"net/http"
"sort"
"github.com/aerogo/aero"
"github.com/animenotifier/notify.moe/components"
"github.com/animenotifier/notify.moe/utils"
)
// Get anime list in the browser extension.
func Get(ctx *aero.Context) string {
user := utils.GetUser(ctx)
if user == nil {
return ctx.Error(http.StatusUnauthorized, "Not logged in", nil)
}
animeList := user.AnimeList()
if animeList == nil {
return ctx.Error(http.StatusNotFound, "Anime list not found", nil)
}
sort.Slice(animeList.Items, func(i, j int) bool {
return animeList.Items[i].FinalRating() < animeList.Items[j].FinalRating()
})
return utils.AllowEmbed(ctx, ctx.HTML(components.AnimeList(animeList)))
}

View File

@ -11,5 +11,8 @@ component Forum(tag string, threads []*arn.Thread, threadsPerPage int)
span Load more span Load more
component ThreadList(threads []*arn.Thread) component ThreadList(threads []*arn.Thread)
if len(threads) == 0
p No threads found.
else
each thread in threads each thread in threads
ThreadLink(thread) ThreadLink(thread)

View File

@ -0,0 +1,16 @@
component OldPopularAnime(popularAnime []*arn.Anime, titleCount int, animeCount int)
//- h2 Anime
//- #search-container
//- input#search(type="text", placeholder="Search...", onkeyup="$.searchAnime();", onfocus="this.select();", disabled="disabled", data-count=titleCount, data-anime-count=animeCount)
//- #search-results-container
//- #search-results
//- if popularAnime != nil
//- h3.popular-title Popular
//- .popular-anime-list
//- each anime in popularAnime
//- a.popular-anime.ajax(href="/anime/" + toString(anime.ID), title=anime.Title.Romaji + " (" + arn.Plural(anime.Watching(), "user") + " watching)")
//- img.anime-image.popular-anime-image(src=anime.Image, alt=anime.Title.Romaji)

View File

@ -0,0 +1,19 @@
// .popular-title
// text-align center
// .popular-anime-list
// display flex
// flex-flow row wrap
// justify-content center
// .popular-anime
// padding 0.5em
// display block
// .popular-anime-image
// width 100px !important
// height 141px !important
// border-radius 3px
// object-fit cover
// default-transition
// shadow-up

View File

@ -1,6 +1,8 @@
package popularanime package popularanime
import ( import (
"net/http"
"github.com/aerogo/aero" "github.com/aerogo/aero"
"github.com/animenotifier/arn" "github.com/animenotifier/arn"
"github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/components"
@ -8,29 +10,10 @@ import (
// Get search page. // Get search page.
func Get(ctx *aero.Context) string { func Get(ctx *aero.Context) string {
// titleCount := 0
// animeCount := 0
// // let info: any = await bluebird.props({
// // popular: arn.db.get('Cache', 'popularAnime'),
// // stats: arn.db.get('Cache', 'animeStats')
// // })
// // return response.render({
// // user,
// // popularAnime: info.popular.anime,
// // animeCount: info.stats.animeCount,
// // titleCount: info.stats.titleCount,
// // anime: null
// // })
// popular, _ := arn.GetPopularCache()
// return ctx.HTML(components.Search(popular.Anime, titleCount, animeCount))
animeList, err := arn.GetPopularAnimeCached() animeList, err := arn.GetPopularAnimeCached()
if err != nil { if err != nil {
return ctx.HTML("There was a problem listing anime!") return ctx.Error(http.StatusInternalServerError, "Error fetching popular anime", err)
} }
return ctx.HTML(components.AnimeGrid(animeList)) return ctx.HTML(components.AnimeGrid(animeList))

View File

@ -1,16 +0,0 @@
component Search(popularAnime []*arn.Anime, titleCount int, animeCount int)
h2 Anime
#search-container
input#search(type="text", placeholder="Search...", onkeyup="$.searchAnime();", onfocus="this.select();", disabled="disabled", data-count=titleCount, data-anime-count=animeCount)
#search-results-container
#search-results
if popularAnime != nil
h3.popular-title Popular
.popular-anime-list
each anime in popularAnime
a.popular-anime.ajax(href="/anime/" + toString(anime.ID), title=anime.Title.Romaji + " (" + arn.Plural(anime.Watching(), "user") + " watching)")
img.anime-image.popular-anime-image(src=anime.Image, alt=anime.Title.Romaji)

View File

@ -1,19 +0,0 @@
.popular-title
text-align center
.popular-anime-list
display flex
flex-flow row wrap
justify-content center
.popular-anime
padding 0.5em
display block
.popular-anime-image
width 100px !important
height 141px !important
border-radius 3px
object-fit cover
default-transition
shadow-up

View File

@ -2,6 +2,7 @@ package profile
import ( import (
"github.com/aerogo/aero" "github.com/aerogo/aero"
"github.com/aerogo/flow"
"github.com/animenotifier/arn" "github.com/animenotifier/arn"
"github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/components"
"github.com/animenotifier/notify.moe/utils" "github.com/animenotifier/notify.moe/utils"
@ -28,7 +29,7 @@ func Profile(ctx *aero.Context, viewUser *arn.User) string {
var animeList *arn.AnimeList var animeList *arn.AnimeList
var posts []*arn.Post var posts []*arn.Post
aero.Parallel(func() { flow.Parallel(func() {
user = utils.GetUser(ctx) user = utils.GetUser(ctx)
}, func() { }, func() {
animeList = viewUser.AnimeList() animeList = viewUser.AnimeList()

View File

@ -57,7 +57,7 @@ component Profile(viewUser *arn.User, user *arn.User, animeList *arn.AnimeList,
else else
each item in animeList.Items each item in animeList.Items
a.profile-watching-list-item.ajax(href=item.Anime().Link(), title=item.Anime().Title.Canonical + " (" + toString(item.Episodes) + " / " + arn.EpisodesToString(item.Anime().EpisodeCount) + ")") a.profile-watching-list-item.ajax(href=item.Anime().Link(), title=item.Anime().Title.Canonical + " (" + toString(item.Episodes) + " / " + arn.EpisodesToString(item.Anime().EpisodeCount) + ")")
img.anime-cover-image.profile-watching-list-item-image(src=item.Anime().Image.Tiny, alt=item.Anime().Title.Canonical) img.anime-cover-image.profile-watching-list-item-image.lazy(data-src=item.Anime().Image.Tiny, alt=item.Anime().Title.Canonical)
.profile-category.mountable .profile-category.mountable
h3 h3
@ -79,7 +79,11 @@ component Profile(viewUser *arn.User, user *arn.User, animeList *arn.AnimeList,
.post-author .post-author
Avatar(post.Author()) Avatar(post.Author())
.post-content .post-content
.mountable!= aero.Markdown(post.Text) div!= aero.Markdown(post.Text)
.post-toolbar.active .post-toolbar.active
.spacer .spacer
.post-likes= len(post.Likes) .post-likes= len(post.Likes)
//- if user != nil && user.Role == "admin"
//- .footer
//- a(href="/api/user/" + viewUser.ID) User API

View File

@ -7,3 +7,7 @@
.profile-watching-list-item-image .profile-watching-list-item-image
width 55px !important width 55px !important
border-radius 2px border-radius 2px
< 380px
.profile-watching-list
justify-content center

View File

@ -14,5 +14,5 @@ func Get(ctx *aero.Context) string {
term := ctx.Get("term") term := ctx.Get("term")
userResults, animeResults := arn.Search(term, maxUsers, maxAnime) userResults, animeResults := arn.Search(term, maxUsers, maxAnime)
return ctx.HTML(components.Search(userResults, animeResults)) return ctx.HTML(components.SearchResults(userResults, animeResults))
} }

View File

@ -1,4 +1,4 @@
component Search(users []*arn.User, animeResults []*arn.Anime) component SearchResults(users []*arn.User, animeResults []*arn.Anime)
.widgets .widgets
.widget .widget
h3 Users h3 Users

View File

@ -16,5 +16,5 @@ func Get(ctx *aero.Context) string {
return ctx.Error(http.StatusForbidden, "Not logged in", nil) return ctx.Error(http.StatusForbidden, "Not logged in", nil)
} }
return ctx.HTML(components.Settings(user)) return utils.AllowEmbed(ctx, ctx.HTML(components.Settings(user)))
} }

View File

@ -1,7 +1,7 @@
component Settings(user *arn.User) component Settings(user *arn.User)
h2.page-title Settings h2.page-title Settings
.widgets(data-api="/api/user/" + user.ID) .widgets
.widget.mountable .widget.mountable(data-api="/api/user/" + user.ID)
h3.widget-title h3.widget-title
Icon("user") Icon("user")
span Personal span Personal
@ -10,7 +10,7 @@ component Settings(user *arn.User)
InputText("Tagline", user.Tagline, "Tagline", "Text that appears below your username") InputText("Tagline", user.Tagline, "Tagline", "Text that appears below your username")
InputText("Website", user.Website, "Website", "Your homepage") InputText("Website", user.Website, "Website", "Your homepage")
.widget.mountable .widget.mountable(data-api="/api/user/" + user.ID)
h3.widget-title h3.widget-title
Icon("cubes") Icon("cubes")
span Accounts span Accounts
@ -18,3 +18,11 @@ component Settings(user *arn.User)
InputText("Accounts.AniList.Nick", user.Accounts.AniList.Nick, "AniList", "Your username on anilist.co") InputText("Accounts.AniList.Nick", user.Accounts.AniList.Nick, "AniList", "Your username on anilist.co")
InputText("Accounts.MyAnimeList.Nick", user.Accounts.MyAnimeList.Nick, "MyAnimeList", "Your username on myanimelist.net") InputText("Accounts.MyAnimeList.Nick", user.Accounts.MyAnimeList.Nick, "MyAnimeList", "Your username on myanimelist.net")
InputText("Accounts.Kitsu.Nick", user.Accounts.Kitsu.Nick, "Kitsu", "Your username on kitsu.io") InputText("Accounts.Kitsu.Nick", user.Accounts.Kitsu.Nick, "Kitsu", "Your username on kitsu.io")
InputText("Accounts.AnimePlanet.Nick", user.Accounts.AnimePlanet.Nick, "AnimePlanet", "Your username on anime-planet.com")
//- .widget.mountable(data-api="/api/settings/" + user.ID)
//- h3.widget-title
//- Icon("cogs")
//- span Settings
//- InputText("TitleLanguage", user.Settings().TitleLanguage, "Title language", "Language of anime titles")

View File

@ -0,0 +1,29 @@
package main
import (
"github.com/animenotifier/arn"
)
func main() {
// Get a stream of all users
allUsers, err := arn.AllUsers()
if err != nil {
panic(err)
}
// Iterate over the stream
for user := range allUsers {
if user.LastSeen != "" {
continue
}
user.LastSeen = user.LastLogin
if user.LastSeen == "" {
user.LastSeen = user.Registered
}
user.Save()
}
}

View File

@ -25,7 +25,7 @@ func main() {
count++ count++
println(count, user.Nick) println(count, user.Nick)
user.SetNick(user.Nick) user.ForceSetNick(user.Nick)
if user.Email != "" { if user.Email != "" {
user.SetEmail(user.Email) user.SetEmail(user.Email)

View File

@ -5,9 +5,34 @@ import * as actions from "./actions"
export class AnimeNotifier { export class AnimeNotifier {
app: Application app: Application
visibilityObserver: IntersectionObserver
constructor(app: Application) { constructor(app: Application) {
this.app = app this.app = app
if("IntersectionObserver" in window) {
// Enable lazy load
this.visibilityObserver = new IntersectionObserver(
entries => {
for(let entry of entries) {
if(entry.intersectionRatio > 0) {
entry.target["became visible"]()
this.visibilityObserver.unobserve(entry.target)
}
}
},
{}
)
} else {
// Disable lazy load feature
this.visibilityObserver = {
disconnect: () => {},
observe: (elem: HTMLElement) => {
elem["became visible"]()
},
unobserve: (elem: HTMLElement) => {}
} as IntersectionObserver
}
} }
onReadyStateChange() { onReadyStateChange() {
@ -24,12 +49,22 @@ export class AnimeNotifier {
this.app.run() this.app.run()
} }
onContentLoaded() {
this.visibilityObserver.disconnect()
// Update each of these asynchronously
Promise.resolve().then(() => this.updateMountables())
Promise.resolve().then(() => this.updateActions())
Promise.resolve().then(() => this.lazyLoadImages())
}
reloadContent() { reloadContent() {
return fetch("/_" + this.app.currentPath, { return fetch("/_" + this.app.currentPath, {
credentials: "same-origin" credentials: "same-origin"
}) })
.then(response => response.text()) .then(response => response.text())
.then(html => Diff.innerHTML(this.app.content, html)) .then(html => Diff.innerHTML(this.app.content, html))
.then(() => this.app.emit("DOMContentLoaded"))
} }
loading(isLoading: boolean) { loading(isLoading: boolean) {
@ -42,32 +77,48 @@ export class AnimeNotifier {
updateActions() { updateActions() {
for(let element of findAll("action")) { for(let element of findAll("action")) {
if(element["action assigned"]) {
continue
}
let actionName = element.dataset.action let actionName = element.dataset.action
element.addEventListener(element.dataset.trigger, e => { element.addEventListener(element.dataset.trigger, e => {
actions[actionName](this, element, e) actions[actionName](this, element, e)
}) })
element.classList.remove("action") // Use "action assigned" flag instead of removing the class.
// This will make sure that DOM diffs which restore the class name
// will not assign the action multiple times to the same element.
element["action assigned"] = true
} }
} }
updateAvatars() { lazyLoadImages() {
for(let element of findAll("user-image")) { for(let element of findAll("lazy")) {
let img = element as HTMLImageElement this.lazyLoadImage(element as HTMLImageElement)
}
}
lazyLoadImage(img: HTMLImageElement) {
// Once the image becomes visible, load it
img["became visible"] = () => {
img.src = img.dataset.src
if(img.naturalWidth === 0) { if(img.naturalWidth === 0) {
img.onload = function() { img.onload = function() {
this.classList.add("user-image-found") this.classList.add("image-found")
} }
img.onerror = function() { img.onerror = function() {
this.classList.add("user-image-not-found") this.classList.add("image-not-found")
} }
} else { } else {
img.classList.add("user-image-found") img.classList.add("image-found")
} }
} }
this.visibilityObserver.observe(img)
} }
updateMountables() { updateMountables() {
@ -89,13 +140,6 @@ export class AnimeNotifier {
} }
} }
onContentLoaded() {
// Update each of these asynchronously
Promise.resolve().then(() => this.updateMountables())
Promise.resolve().then(() => this.updateAvatars())
Promise.resolve().then(() => this.updateActions())
}
onPopState(e: PopStateEvent) { onPopState(e: PopStateEvent) {
if(e.state) { if(e.state) {
this.app.load(e.state, { this.app.load(e.state, {
@ -109,8 +153,15 @@ export class AnimeNotifier {
} }
onKeyDown(e: KeyboardEvent) { onKeyDown(e: KeyboardEvent) {
// Ctrl + Q = Search // Ignore hotkeys on input elements
if(e.ctrlKey && e.keyCode == 81) { switch(document.activeElement.tagName) {
case "INPUT":
case "TEXTAREA":
return
}
// F = Search
if(e.keyCode == 70) {
let search = this.app.find("search") as HTMLInputElement let search = this.app.find("search") as HTMLInputElement
search.focus() search.focus()

View File

@ -25,6 +25,10 @@ export class Diff {
} }
if(a.nodeType === Node.ELEMENT_NODE) { if(a.nodeType === Node.ELEMENT_NODE) {
if(a.tagName === "IFRAME") {
continue
}
let removeAttributes: Attr[] = [] let removeAttributes: Attr[] = []
for(let x = 0; x < a.attributes.length; x++) { for(let x = 0; x < a.attributes.length; x++) {
@ -48,6 +52,11 @@ export class Diff {
a.setAttribute(attrib.name, b.getAttribute(attrib.name)) a.setAttribute(attrib.name, b.getAttribute(attrib.name))
} }
} }
// Special case: Apply state of input elements
if(a !== document.activeElement && a instanceof HTMLInputElement && b instanceof HTMLInputElement) {
a.value = b.value
}
} }
Diff.childNodes(a, b) Diff.childNodes(a, b)

View File

@ -48,6 +48,8 @@ export function save(arn: AnimeNotifier, input: HTMLInputElement | HTMLTextAreaE
.then(() => { .then(() => {
arn.loading(false) arn.loading(false)
input.disabled = false input.disabled = false
return arn.reloadContent()
}) })
} }

36
styles/extension.scarlet Normal file
View File

@ -0,0 +1,36 @@
.embedded
flex-direction column-reverse !important
.anime-list
max-width 500px
margin -1.1rem
thead
display none
.anime-list-item
// ui-element
// margin-bottom 0.5rem
.anime-list-item-episodes
horizontal
text-align right
white-space nowrap
flex 0.2
.anime-list-item-episodes-watched
flex 0.4
.anime-list-item-episodes-max
opacity 0.5
flex 0.4
.anime-list-item-episodes-separator
opacity 0.5
flex 0.2
.anime-list-item-rating
display none
#navigation
font-size 0.9rem

View File

@ -3,9 +3,19 @@
// margin-bottom 1rem // margin-bottom 1rem
.thread-link .thread-link
horizontal vertical
margin 0.25rem 0 margin 0.25rem 0
.post-author
margin-bottom 0.25rem
> 330px
.thread-link
horizontal
.post-author
margin-bottom 0
.post-content .post-content
ui-element ui-element
flex-grow 1 flex-grow 1

View File

@ -32,6 +32,7 @@ mixin grid-image
height 100% height 100%
border-radius 3px border-radius 3px
object-fit cover object-fit cover
default-transition
mixin grid-icon mixin grid-icon
font-size 2.5rem font-size 2.5rem

11
styles/images.scarlet Normal file
View File

@ -0,0 +1,11 @@
.lazy
visibility hidden
opacity 0
.image-found
visibility visible
opacity 1 !important
.image-not-found
visibility hidden
opacity 0 !important

View File

@ -1,3 +1,10 @@
mixin input-focus
:focus
color black
border 1px solid main-color
// TODO: Replace with alpha(main-color, 20%) function
box-shadow 0 0 6px rgba(248, 165, 130, 0.2)
input, textarea, button, select input, textarea, button, select
font-family inherit font-family inherit
font-size 1em font-size 1em
@ -9,16 +16,15 @@ input, textarea
border ui-border border ui-border
background white background white
box-shadow none box-shadow none
input-focus
:focus
color black
border 1px solid main-color
// TODO: Replace with alpha(main-color, 20%) function
box-shadow 0 0 6px rgba(248, 165, 130, 0.2)
:disabled :disabled
ui-disabled ui-disabled
// We need this to have a selector with a higher priority than .widget-element:focus
input.widget-element
input-focus
button, .button button, .button
ui-element ui-element
horizontal horizontal

View File

@ -4,13 +4,6 @@
height avatar-size height avatar-size
border-radius 100% border-radius 100%
object-fit cover object-fit cover
opacity 0
default-transition default-transition
:hover :hover
box-shadow outline-shadow-heavy box-shadow outline-shadow-heavy
.user-image-found
opacity 1 !important
.user-image-not-found
opacity 0 !important

View File

@ -10,6 +10,10 @@ var tests = map[string][]string{
"/+Akyoto/threads", "/+Akyoto/threads",
}, },
"/user/:nick/posts": []string{
"/+Akyoto/posts",
},
"/user/:nick/animelist": []string{ "/user/:nick/animelist": []string{
"/+Akyoto/animelist", "/+Akyoto/animelist",
}, },
@ -56,6 +60,14 @@ var tests = map[string][]string{
"/api/animelist/4J6qpK1ve", "/api/animelist/4J6qpK1ve",
}, },
"/api/animelist/:id/get/:item": []string{
"/api/animelist/4J6qpK1ve/get/7929",
},
"/api/animelist/:id/get/:item/:property": []string{
"/api/animelist/4J6qpK1ve/get/7929/Episodes",
},
"/api/settings/:id": []string{ "/api/settings/:id": []string{
"/api/settings/4J6qpK1ve", "/api/settings/4J6qpK1ve",
}, },

10
utils/allowembed.go Normal file
View File

@ -0,0 +1,10 @@
package utils
import "github.com/aerogo/aero"
// AllowEmbed allows the page to be called by the browser extension.
func AllowEmbed(ctx *aero.Context, response string) string {
// This is a bit of a hack.
ctx.SetResponseHeader("X-Frame-Options", "ALLOW-FROM chrome-extension://hjfcooigdelogjmniiahfiilcefdlpha/options.html")
return response
}

15
utils/container.go Normal file
View File

@ -0,0 +1,15 @@
package utils
import (
"github.com/aerogo/aero"
)
// GetContainerClass returns the class for the "container" element.
// In the browser extension it will get the "embedded" class.
func GetContainerClass(ctx *aero.Context) string {
if ctx.URI() == "/extension/embed" {
return "embedded"
}
return ""
}

View File

@ -1,17 +0,0 @@
package utils
import (
"os"
"strings"
)
// IsProduction returns true if the hostname contains "arn".
func IsProduction() bool {
host, _ := os.Hostname()
return strings.Contains(host, "arn")
}
// IsDevelopment returns true if the hostname does not contain "arn".
func IsDevelopment() bool {
return !IsProduction()
}

View File

@ -5,23 +5,7 @@ import (
"github.com/animenotifier/arn" "github.com/animenotifier/arn"
) )
// GetUser ... // GetUser returns the logged in user for the given context.
func GetUser(ctx *aero.Context) *arn.User { func GetUser(ctx *aero.Context) *arn.User {
if !ctx.HasSession() { return arn.GetUserFromContext(ctx)
return nil
}
userID := ctx.Session().GetString("userId")
if userID == "" {
return nil
}
user, err := arn.GetUser(userID)
if err != nil {
return nil
}
return user
} }