Added badge collection changes
BIN
Halsey - Without Me (Lyrics)/Halsey - Without Me (Lyrics).mp3
Normal file
|
|
@ -103,6 +103,13 @@
|
|||
<div v-show="isPodcastDownloadQueuePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<!-- ✅ New: My Badges -->
|
||||
<nuxt-link :to="`/my-badges`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isBadgesPage ? 'bg-primary/80' : 'bg-bg/60'">
|
||||
<span class="material-symbols text-2xl">workspace_premium</span>
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonBadges || 'My Badges' }}</p>
|
||||
<div v-show="isBadgesPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-error/40 cursor-pointer relative" :class="showingIssues ? 'bg-error/40' : 'bg-error/20'">
|
||||
<span class="material-symbols text-2xl">warning</span>
|
||||
|
||||
|
|
@ -191,6 +198,10 @@ export default {
|
|||
isStatsPage() {
|
||||
return this.$route.name === 'library-library-stats'
|
||||
},
|
||||
/* ✅ highlight for My Badges pages */
|
||||
isBadgesPage() {
|
||||
return this.$route.name === 'my-badges' || this.$route.name === 'my-badges-collectionId'
|
||||
},
|
||||
libraryBookshelfPage() {
|
||||
return this.$route.name === 'library-library-bookshelf-id'
|
||||
},
|
||||
|
|
|
|||
25
client/components/badges/BadgeGrid.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<template>
|
||||
<div class="grid gap-4 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
|
||||
<BadgeTile
|
||||
v-for="b in badges"
|
||||
:key="b.id"
|
||||
:badge="b"
|
||||
:locked="!unlockedIdsSet.has(b.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BadgeTile from './BadgeTile.vue'
|
||||
|
||||
export default {
|
||||
components: { BadgeTile },
|
||||
props: {
|
||||
badges: { type: Array, required: true },
|
||||
unlockedIds: { type: [Array, Set], default: () => [] }
|
||||
},
|
||||
computed: {
|
||||
unlockedIdsSet () { return this.unlockedIds instanceof Set ? this.unlockedIds : new Set(this.unlockedIds) }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
44
client/components/badges/BadgeTile.vue
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<template>
|
||||
<div class="rounded-2xl bg-bg/60 border border-primary/20 p-3 flex flex-col items-center text-center">
|
||||
<img
|
||||
:src="src"
|
||||
alt=""
|
||||
class="w-20 h-20 object-contain select-none"
|
||||
:class="locked ? 'filter grayscale opacity-60' : ''"
|
||||
@error="onImgError"
|
||||
draggable="false"
|
||||
/>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm font-medium">{{ badge.name }}</p>
|
||||
<p v-if="badge.blurb" class="text-xxs text-gray-400 mt-0.5">{{ badge.blurb }}</p>
|
||||
</div>
|
||||
<div v-if="!locked" class="mt-1 text-xxs text-green-400">Unlocked ✓</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
badge: { type: Object, required: true },
|
||||
locked: { type: Boolean, default: true }
|
||||
},
|
||||
data () {
|
||||
return { imgError: false }
|
||||
},
|
||||
computed: {
|
||||
src () {
|
||||
if (this.imgError) {
|
||||
// Simple inline SVG placeholder
|
||||
return `data:image/svg+xml;utf8,` + encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160"><rect width="100%" height="100%" fill="#1f2937"/><text x="50%" y="52%" fill="#9ca3af" font-size="12" text-anchor="middle" font-family="sans-serif">${this.badge.name}</text></svg>`
|
||||
)
|
||||
}
|
||||
// your files are under /static/badges and named like new_reader.png etc.
|
||||
return `/badges/${this.badge.id}.png`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onImgError () { this.imgError = true }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
18
client/components/badges/CollectionCard.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<template>
|
||||
<nuxt-link :to="to" class="block rounded-2xl bg-bg/60 border border-primary/30 hover:bg-primary/10 transition p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold">{{ title }}</h3>
|
||||
<span class="text-xs text-gray-400">{{ count }} badges</span>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
title: { type: String, required: true },
|
||||
count: { type: Number, required: true },
|
||||
to: { type: String, required: true }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
56
client/components/modals/AchievementPopup.vue
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<template>
|
||||
<transition name="abs-pop-fade">
|
||||
<div
|
||||
v-if="value"
|
||||
class="fixed inset-0 z-[2000] flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Achievement unlocked"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60" @click="$emit('input', false)" aria-hidden="true" />
|
||||
<div class="relative w-full max-w-sm rounded-2xl shadow-2xl ring-1 ring-black/20 bg-gray-900 text-gray-50 p-6">
|
||||
<button class="absolute right-2 top-2 p-1 text-gray-400 hover:text-white" @click="$emit('input', false)" aria-label="Close">✕</button>
|
||||
|
||||
<div class="w-16 h-16 rounded-xl mb-3 grid place-items-center mx-auto"
|
||||
:style="{ background: 'linear-gradient(135deg,#1f2937,#0b1220)' }">
|
||||
<svg viewBox="0 0 24 24" class="w-9 h-9">
|
||||
<path fill="currentColor" d="M12 2l3 5 5 .5-3.7 3.1L17 16l-5-2.6L7 16l.7-5.4L4 7.5 9 7l3-5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h2 class="text-2xl font-bold text-center">Congratulations!</h2>
|
||||
<p class="text-center text-gray-300 mt-1">
|
||||
You unlocked: <span class="font-semibold">{{ badge?.name || 'Badge' }}</span>
|
||||
</p>
|
||||
|
||||
<div class="mt-5 flex justify-center">
|
||||
<button class="px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500" @click="$emit('input', false)">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'AchievementPopup',
|
||||
props: { value: { type: Boolean, default: false }, badge: { type: Object, default: null } },
|
||||
mounted () {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('achievement:unlocked', (e) => {
|
||||
const [first] = e.detail?.badges || []
|
||||
if (first) {
|
||||
this.$emit('input', true)
|
||||
this.$data.last = first
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
data () { return { last: null } }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.abs-pop-fade-enter-active, .abs-pop-fade-leave-active { transition: opacity .2s ease, transform .2s ease }
|
||||
.abs-pop-fade-enter-from, .abs-pop-fade-leave-to { opacity: 0; transform: scale(0.98) }
|
||||
</style>
|
||||
|
|
@ -6,7 +6,6 @@ const serverPaths = ['api/', 'public/', 'hls/', 'auth/', 'feed/', 'status', 'log
|
|||
const proxy = Object.fromEntries(serverPaths.map((path) => [`${routerBasePath}/${path}`, { target: process.env.NODE_ENV !== 'production' ? serverHostUrl : '/' }]))
|
||||
|
||||
module.exports = {
|
||||
// Disable server-side rendering: https://go.nuxtjs.dev/ssr-mode
|
||||
ssr: false,
|
||||
target: 'static',
|
||||
dev: process.env.NODE_ENV !== 'production',
|
||||
|
|
@ -21,12 +20,9 @@ module.exports = {
|
|||
routerBasePath
|
||||
},
|
||||
|
||||
// Global page headers: https://go.nuxtjs.dev/config-head
|
||||
head: {
|
||||
title: 'Audiobookshelf',
|
||||
htmlAttrs: {
|
||||
lang: 'en'
|
||||
},
|
||||
htmlAttrs: { lang: 'en' },
|
||||
meta: [{ charset: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1' }, { hid: 'description', name: 'description', content: '' }, { hid: 'robots', name: 'robots', content: 'noindex' }],
|
||||
script: [],
|
||||
link: [
|
||||
|
|
@ -35,49 +31,41 @@ module.exports = {
|
|||
]
|
||||
},
|
||||
|
||||
router: {
|
||||
base: routerBasePath
|
||||
},
|
||||
router: { base: routerBasePath },
|
||||
|
||||
// Global CSS: https://go.nuxtjs.dev/config-css
|
||||
css: ['@/assets/tailwind.css', '@/assets/app.css'],
|
||||
|
||||
// Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins
|
||||
plugins: ['@/plugins/constants.js', '@/plugins/init.client.js', '@/plugins/axios.js', '@/plugins/toast.js', '@/plugins/utils.js', '@/plugins/i18n.js'],
|
||||
plugins: [
|
||||
'@/plugins/constants.js',
|
||||
'@/plugins/init.client.js',
|
||||
'@/plugins/axios.js',
|
||||
'@/plugins/toast.js',
|
||||
'@/plugins/utils.js',
|
||||
'@/plugins/i18n.js',
|
||||
|
||||
// Auto import components: https://go.nuxtjs.dev/config-components
|
||||
components: true,
|
||||
|
||||
// Modules for dev and build (recommended): https://go.nuxtjs.dev/config-modules
|
||||
buildModules: [
|
||||
// https://go.nuxtjs.dev/tailwindcss
|
||||
'@nuxtjs/pwa'
|
||||
// ✅ NEW: robust achievement hooks
|
||||
'@/plugins/achievements.routes.client.js',
|
||||
'@/plugins/achievements.axios.client.js',
|
||||
'@/plugins/achievement-hooks.client.js'
|
||||
],
|
||||
|
||||
// Modules: https://go.nuxtjs.dev/config-modules
|
||||
components: true,
|
||||
|
||||
buildModules: ['@nuxtjs/pwa'],
|
||||
|
||||
modules: ['nuxt-socket-io', '@nuxtjs/axios', '@nuxtjs/proxy'],
|
||||
|
||||
proxy,
|
||||
|
||||
io: {
|
||||
sockets: [
|
||||
{
|
||||
name: 'dev',
|
||||
url: serverHostUrl
|
||||
},
|
||||
{
|
||||
name: 'prod'
|
||||
}
|
||||
]
|
||||
sockets: [{ name: 'dev', url: serverHostUrl }, { name: 'prod' }]
|
||||
},
|
||||
|
||||
// Axios module configuration: https://go.nuxtjs.dev/config-axios
|
||||
axios: {
|
||||
baseURL: routerBasePath,
|
||||
progress: false
|
||||
},
|
||||
|
||||
// nuxt/pwa https://pwa.nuxtjs.org
|
||||
pwa: {
|
||||
icon: false,
|
||||
meta: {
|
||||
|
|
@ -93,44 +81,18 @@ module.exports = {
|
|||
display: 'standalone',
|
||||
background_color: '#232323',
|
||||
icons: [
|
||||
{
|
||||
src: routerBasePath + '/icon.svg',
|
||||
sizes: 'any'
|
||||
},
|
||||
{
|
||||
src: routerBasePath + '/icon192.png',
|
||||
type: 'image/png',
|
||||
sizes: 'any'
|
||||
}
|
||||
{ src: routerBasePath + '/icon.svg', sizes: 'any' },
|
||||
{ src: routerBasePath + '/icon192.png', type: 'image/png', sizes: 'any' }
|
||||
]
|
||||
},
|
||||
workbox: {
|
||||
offline: false,
|
||||
cacheAssets: false,
|
||||
preCaching: [],
|
||||
runtimeCaching: []
|
||||
}
|
||||
workbox: { offline: false, cacheAssets: false, preCaching: [], runtimeCaching: [] }
|
||||
},
|
||||
|
||||
// Build Configuration: https://go.nuxtjs.dev/config-build
|
||||
build: {},
|
||||
watchers: {
|
||||
webpack: {
|
||||
aggregateTimeout: 300,
|
||||
poll: 1000
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: process.env.NODE_ENV === 'production' ? 80 : 3000,
|
||||
host: '0.0.0.0'
|
||||
},
|
||||
watchers: { webpack: { aggregateTimeout: 300, poll: 1000 } },
|
||||
server: { port: process.env.NODE_ENV === 'production' ? 80 : 3000, host: '0.0.0.0' },
|
||||
|
||||
/**
|
||||
* Temporary workaround for @nuxt-community/tailwindcss-module.
|
||||
*
|
||||
* Reported: 2022-05-23
|
||||
* See: [Issue tracker](https://github.com/nuxt-community/tailwindcss-module/issues/480)
|
||||
*/
|
||||
// tailwind workaround
|
||||
devServerHandlers: [],
|
||||
|
||||
ignore: ['**/*.test.*', '**/*.cy.*']
|
||||
|
|
|
|||
|
|
@ -74,9 +74,22 @@ export default {
|
|||
this.$refs.bookshelf.setShelvesFromSearch()
|
||||
}
|
||||
})
|
||||
// 🔔 count this search toward Search Master
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('abs:search-performed'))
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
mounted() {
|
||||
// count the initial load as a search as well
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('abs:search-performed'))
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
95
client/pages/my-badges/_collectionId.vue
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<template>
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<nuxt-link to="/my-badges" class="text-primary hover:underline text-sm">← All Collections</nuxt-link>
|
||||
<h1 class="text-2xl font-semibold">{{ title }}</h1>
|
||||
<span v-if="ready && collection" class="text-sm text-gray-400 ml-auto">
|
||||
{{ unlockedInThis }}/{{ totalInThis }} unlocked
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="text-error mb-4">{{ error }}</div>
|
||||
<div v-else-if="!ready" class="text-gray-300">Loading…</div>
|
||||
<div v-else-if="!collection" class="text-gray-300">Collection not found.</div>
|
||||
<div v-else>
|
||||
<BadgeGrid :badges="badgesInCollection" :unlocked-ids="unlockedSet" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AchievementService from '@/services/AchievementService'
|
||||
import BadgeGrid from '@/components/badges/BadgeGrid.vue'
|
||||
|
||||
export default {
|
||||
components: { BadgeGrid },
|
||||
data () {
|
||||
return {
|
||||
catalog: null,
|
||||
my: { unlocked: [] },
|
||||
collection: null,
|
||||
error: '',
|
||||
unlisten: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
ready () { return !!this.catalog },
|
||||
title () { return this.collection?.name || 'Reading Journey' },
|
||||
badgesInCollection () {
|
||||
if (!this.collection || !this.catalog) return []
|
||||
// catalog.badges is an object keyed by id
|
||||
return (this.collection.badgeIds || [])
|
||||
.map(id => this.catalog.badges[id])
|
||||
.filter(Boolean)
|
||||
},
|
||||
unlockedSet () {
|
||||
return new Set(this.my?.unlocked || [])
|
||||
},
|
||||
unlockedInThis () {
|
||||
const ids = new Set(this.collection?.badgeIds || [])
|
||||
let n = 0
|
||||
for (const id of this.my?.unlocked || []) if (ids.has(id)) n++
|
||||
return n
|
||||
},
|
||||
totalInThis () {
|
||||
return this.collection?.badgeIds?.length || 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'$route.params.collectionId': {
|
||||
immediate: true,
|
||||
async handler () {
|
||||
await this.load()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
// If a badge is unlocked elsewhere, refresh progress so tiles flip to color
|
||||
const onUnlocked = async () => {
|
||||
try { this.my = await AchievementService.getMy() } catch (_) {}
|
||||
}
|
||||
window.addEventListener('achievement:unlocked', onUnlocked)
|
||||
this.unlisten = () => window.removeEventListener('achievement:unlocked', onUnlocked)
|
||||
},
|
||||
beforeDestroy () {
|
||||
if (this.unlisten) this.unlisten()
|
||||
},
|
||||
methods: {
|
||||
async load () {
|
||||
this.error = ''
|
||||
try {
|
||||
const [catalog, my] = await Promise.all([
|
||||
AchievementService.getCatalog(),
|
||||
AchievementService.getMy()
|
||||
])
|
||||
this.catalog = catalog
|
||||
this.my = my || { unlocked: [] }
|
||||
const cid = this.$route.params.collectionId
|
||||
this.collection = this.catalog.collections.find(c => c.id === cid)
|
||||
} catch (e) {
|
||||
this.error = e?.message || 'Failed to load collection'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
80
client/pages/my-badges/index.vue
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<template>
|
||||
<div class="px-6 py-4">
|
||||
<h1 class="text-2xl font-semibold mb-4">My Badges</h1>
|
||||
|
||||
<div v-if="error" class="text-error mb-4">{{ error }}</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<p class="text-sm text-gray-300">
|
||||
{{ catalogLoaded ? 'Choose a collection to explore your badges.' : 'Loading…' }}
|
||||
</p>
|
||||
<p v-if="catalogLoaded" class="text-sm text-gray-300">
|
||||
Progress: <span class="font-semibold">{{ unlockedCount }}</span> unlocked
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="catalogLoaded" class="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<CollectionCard
|
||||
v-for="c in catalog.collections"
|
||||
:key="c.id"
|
||||
:title="c.name"
|
||||
:count="c.badgeIds.length"
|
||||
:to="`/my-badges/${c.id}`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AchievementService from '@/services/AchievementService'
|
||||
import CollectionCard from '@/components/badges/CollectionCard.vue'
|
||||
|
||||
export default {
|
||||
components: { CollectionCard },
|
||||
data () {
|
||||
return {
|
||||
catalog: null,
|
||||
my: { userId: 'guest', counters: {}, unlocked: [], history: [] },
|
||||
error: '',
|
||||
unlisten: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
catalogLoaded () { return !!this.catalog },
|
||||
// Defensive: only count IDs that exist in the catalog (avoids mismatch)
|
||||
unlockedCount () {
|
||||
if (!this.catalog) return 0
|
||||
const valid = new Set(Object.keys(this.catalog.badges || {}))
|
||||
return (this.my?.unlocked || []).filter(id => valid.has(id)).length
|
||||
}
|
||||
},
|
||||
async mounted () {
|
||||
await this.load()
|
||||
// keep progress fresh when a badge is unlocked elsewhere
|
||||
const onUnlocked = async () => {
|
||||
try { this.my = await AchievementService.getMy() } catch (_) {}
|
||||
}
|
||||
window.addEventListener('achievement:unlocked', onUnlocked)
|
||||
this.unlisten = () => window.removeEventListener('achievement:unlocked', onUnlocked)
|
||||
},
|
||||
beforeDestroy () { if (this.unlisten) this.unlisten() },
|
||||
methods: {
|
||||
async load () {
|
||||
try {
|
||||
const [catalog, my] = await Promise.all([
|
||||
AchievementService.getCatalog(),
|
||||
AchievementService.getMy()
|
||||
])
|
||||
this.catalog = catalog
|
||||
this.my = my || this.my
|
||||
} catch (e) {
|
||||
this.error = e?.message || 'Failed to load achievements'
|
||||
if (!this.catalog) {
|
||||
try { this.catalog = await AchievementService.getCatalog() } catch (_) {}
|
||||
}
|
||||
if (!this.my) this.my = { userId: 'guest', counters: {}, unlocked: [], history: [] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import LocalAudioPlayer from './LocalAudioPlayer'
|
||||
import CastPlayer from './CastPlayer'
|
||||
import AudioTrack from './AudioTrack'
|
||||
import AchievementService from '@/services/AchievementService'
|
||||
|
||||
export default class PlayerHandler {
|
||||
constructor(ctx) {
|
||||
|
|
@ -136,6 +137,13 @@ export default class PlayerHandler {
|
|||
this.sendProgressSync(currentTime)
|
||||
|
||||
this.ctx.mediaFinished(this.libraryItemId, this.episodeId)
|
||||
|
||||
// ✅ Notify Achievements that this library item has been finished.
|
||||
// This is used to count UNIQUE finished items (for Reading Journey).
|
||||
try {
|
||||
// fire-and-forget; no await so UI stays snappy
|
||||
AchievementService.complete({ event: 'itemFinished', meta: { itemId: this.libraryItemId } })
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
playerStateChange(state) {
|
||||
|
|
|
|||
74
client/plugins/achievement-hooks.client.js
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// client/plugins/achievement-hooks.client.js
|
||||
import AchievementService from '@/services/AchievementService'
|
||||
|
||||
export default ({ app }, inject) => {
|
||||
const seen = new Set()
|
||||
|
||||
const once = async (key, fn) => {
|
||||
try {
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
await fn()
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// --------------------- DAILY LOGIN PING (UTC-day safe) --------------------
|
||||
const todayKey = () => Math.floor(Date.now() / (24 * 60 * 60 * 1000)) // UTC-ish
|
||||
const storageKey = () => `abs:login-ping:${todayKey()}`
|
||||
const trySendDailyLoginPing = async () => {
|
||||
const k = storageKey()
|
||||
if (sessionStorage.getItem(k)) return
|
||||
|
||||
// Ensure we are authenticated before we count a login
|
||||
const me = await AchievementService.getMy().catch(() => null)
|
||||
if (me && me.userId && me.userId !== 'guest') {
|
||||
await AchievementService.complete({ event: 'userLoggedIn' })
|
||||
sessionStorage.setItem(k, '1')
|
||||
}
|
||||
}
|
||||
|
||||
if (process.browser) {
|
||||
// fire on app load and after each route change (first successful auth wins)
|
||||
trySendDailyLoginPing()
|
||||
app.router.afterEach(() => trySendDailyLoginPing())
|
||||
|
||||
// also check again every ~15 minutes in long-lived tabs crossing midnight
|
||||
setInterval(() => trySendDailyLoginPing(), 15 * 60 * 1000)
|
||||
}
|
||||
|
||||
// -------------------------- NAVIGATION HOOKS ------------------------------
|
||||
app.router.afterEach((to) => {
|
||||
// Upload page
|
||||
if (to.path.startsWith('/upload')) {
|
||||
once('visitedUpload', () => AchievementService.complete({ event: 'visitedUpload' }))
|
||||
}
|
||||
|
||||
// Authors
|
||||
if (to.path.startsWith('/author') || to.path.startsWith('/authors')) {
|
||||
once('openedAuthor', () => AchievementService.complete({ event: 'openedAuthor' }))
|
||||
}
|
||||
|
||||
// Narrators
|
||||
if (to.path.startsWith('/narrator') || to.path.startsWith('/narrators')) {
|
||||
once('openedNarrator', () => AchievementService.complete({ event: 'openedNarrator' }))
|
||||
}
|
||||
|
||||
// Stats pages (Your Stats / Library Stats)
|
||||
if (to.path.startsWith('/stats') || to.path.startsWith('/your-stats') || to.path.startsWith('/library-stats')) {
|
||||
once('visitedSettings', () => AchievementService.complete({ event: 'visitedSettings' }))
|
||||
}
|
||||
})
|
||||
|
||||
// ------------------------ OPTIONAL ACTION HOOKS ---------------------------
|
||||
if (process.browser) {
|
||||
window.addEventListener('abs:search-performed', () => {
|
||||
AchievementService.complete({ event: 'performedSearch' })
|
||||
})
|
||||
window.addEventListener('abs:playlist-created', () => {
|
||||
AchievementService.complete({ event: 'createdPlaylist' })
|
||||
})
|
||||
window.addEventListener('abs:book-downloaded', () => {
|
||||
AchievementService.complete({ event: 'downloadedBook' })
|
||||
})
|
||||
}
|
||||
}
|
||||
42
client/plugins/achievement-route-hooks.client.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// client/plugins/achievement-route-hooks.client.js
|
||||
export default ({ app }) => {
|
||||
const fire = async (event) => {
|
||||
try {
|
||||
const svc = (await import('@/services/AchievementService')).default
|
||||
svc.complete({ event })
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const base = app.$config?.routerBasePath || ''
|
||||
const stripBase = (p) => (base && p.startsWith(base) ? p.slice(base.length) : p)
|
||||
|
||||
const seen = new Set()
|
||||
|
||||
app.router.afterEach((to) => {
|
||||
const full = to.fullPath || to.path || ''
|
||||
const path = stripBase(full)
|
||||
const purePath = path.split('?')[0]
|
||||
|
||||
if (!seen.has('visited_settings') && purePath.startsWith('/config')) {
|
||||
seen.add('visited_settings')
|
||||
fire('visitedSettings')
|
||||
}
|
||||
if (!seen.has('opened_author') && (purePath.includes('/authors') || /^\/author\//.test(purePath))) {
|
||||
seen.add('opened_author')
|
||||
fire('openedAuthor')
|
||||
}
|
||||
if (!seen.has('opened_narrator') && (purePath.includes('/narrators') || /^\/narrator\//.test(purePath))) {
|
||||
seen.add('opened_narrator')
|
||||
fire('openedNarrator')
|
||||
}
|
||||
|
||||
if (purePath.startsWith('/library/')) {
|
||||
const keys = Object.keys(to.query || {})
|
||||
const effective = keys.filter((k) => !['q', 'page'].includes(k))
|
||||
if (effective.length && !seen.has('applied_filter')) {
|
||||
seen.add('applied_filter')
|
||||
fire('appliedFilter')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
29
client/plugins/achievements-axios-hook.client.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// client/plugins/achievements-axios-hook.client.js
|
||||
import AchievementService from '@/services/AchievementService'
|
||||
|
||||
const hit = (event) => AchievementService.complete({ event }).catch(() => {})
|
||||
|
||||
export default (ctx) => {
|
||||
const isAchUrl = (u) => typeof u === 'string' && u.includes('/api/achievements/')
|
||||
ctx.$axios.onResponse((response) => {
|
||||
try {
|
||||
const { config, status } = response || {}
|
||||
if (!config || status >= 400) return
|
||||
const method = (config.method || 'get').toLowerCase()
|
||||
const url = config.url || ''
|
||||
if (isAchUrl(url)) return
|
||||
|
||||
// Library edited
|
||||
if ((method === 'patch' || method === 'put') && /\/api\/libraries\/[^/]+$/.test(url)) return hit('editedLibrary')
|
||||
|
||||
// Collections & Playlists created
|
||||
if (method === 'post' && /\/api\/collections(\/|$)/.test(url)) return hit('createdCollection')
|
||||
if (method === 'post' && /\/api\/playlists(\/|$)/.test(url)) return hit('createdPlaylist')
|
||||
|
||||
// Series created
|
||||
if (method === 'post' && /\/api\/series(\/|$)/.test(url)) return hit('createdSeries')
|
||||
|
||||
// Book uploaded already backfilled; download is normally not via axios
|
||||
} catch { /* noop */ }
|
||||
})
|
||||
}
|
||||
11
client/plugins/achievements-login.client.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// client/plugins/achievements-login.client.js
|
||||
import AchievementService from '@/services/AchievementService'
|
||||
|
||||
export default () => {
|
||||
// Fire a login event on each app start when the user has a token
|
||||
const hasToken = !!(typeof window !== 'undefined' && (localStorage.getItem('accessToken') || localStorage.getItem('token')))
|
||||
if (!hasToken) return
|
||||
|
||||
// To allow "double dip", we DO send each time the app boots.
|
||||
AchievementService.complete({ event: 'login' }).catch(() => {})
|
||||
}
|
||||
37
client/plugins/achievements.axios.client.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// client/plugins/achievements.axios.client.js
|
||||
import AchievementService from '@/services/AchievementService'
|
||||
|
||||
function matches (url, method, needle, m) {
|
||||
return method === m && url.includes(needle)
|
||||
}
|
||||
|
||||
export default function ({ $axios }) {
|
||||
$axios.onResponse((resp) => {
|
||||
try {
|
||||
const { config, status } = resp || {}
|
||||
const url = (config?.url || '').toLowerCase()
|
||||
const method = (config?.method || 'get').toLowerCase()
|
||||
if (!url || status >= 400) return
|
||||
|
||||
// Edited library
|
||||
if (matches(url, method, '/api/libraries/', 'patch') || matches(url, method, '/api/libraries/', 'put')) {
|
||||
AchievementService.complete({ event: 'editedLibrary' })
|
||||
}
|
||||
|
||||
// Created collection
|
||||
if (matches(url, method, '/api/collections', 'post')) {
|
||||
AchievementService.complete({ event: 'createdCollection' })
|
||||
}
|
||||
|
||||
// Created playlist
|
||||
if (matches(url, method, '/api/playlists', 'post')) {
|
||||
AchievementService.complete({ event: 'createdPlaylist' })
|
||||
}
|
||||
|
||||
// Downloaded a book (server routes often contain /download/)
|
||||
if (url.includes('/download/') || url.includes('/public/download/')) {
|
||||
AchievementService.complete({ event: 'downloadedBook' })
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
}
|
||||
37
client/plugins/achievements.routes.client.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// client/plugins/achievements.routes.client.js
|
||||
import AchievementService from '@/services/AchievementService'
|
||||
|
||||
let fired = new Set()
|
||||
const once = (k, fn) => { if (fired.has(k)) return; fired.add(k); try { fn() } catch {} }
|
||||
|
||||
export default function ({ app }) {
|
||||
const handle = (to) => {
|
||||
const p = to?.path || ''
|
||||
const q = to?.query || {}
|
||||
|
||||
// Author
|
||||
if (/^\/author\/[^/]+$/.test(p)) once('openedAuthor', () => AchievementService.complete({ event: 'openedAuthor' }))
|
||||
|
||||
// Narrators tab
|
||||
if (/^\/library\/[^/]+\/narrators/.test(p)) once('openedNarrator', () => AchievementService.complete({ event: 'openedNarrator' }))
|
||||
|
||||
// ✅ Stats pages (any of these routes count)
|
||||
if (
|
||||
/^\/library\/[^/]+\/stats/.test(p) ||
|
||||
/^\/config\/stats/.test(p) ||
|
||||
/^\/stats(\/|$)/.test(p) ||
|
||||
/^\/your-stats(\/|$)/.test(p)
|
||||
) {
|
||||
once('visitedStats', () => AchievementService.complete({ event: 'visitedStats' }))
|
||||
}
|
||||
|
||||
// Applied filter (search page with any extra query besides q)
|
||||
if (/^\/library\/[^/]+\/search/.test(p)) {
|
||||
const keys = Object.keys(q).filter(k => k !== 'q')
|
||||
if (keys.length) once('appliedFilter', () => AchievementService.complete({ event: 'appliedFilter' }))
|
||||
}
|
||||
}
|
||||
|
||||
handle(app.router.currentRoute)
|
||||
app.router.afterEach((to) => handle(to))
|
||||
}
|
||||
99
client/services/AchievementService.js
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// client/services/AchievementService.js
|
||||
const json = (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
function dispatchUnlocked (badges) {
|
||||
try {
|
||||
if (typeof window !== 'undefined' && badges && badges.length) {
|
||||
window.dispatchEvent(new CustomEvent('achievement:unlocked', { detail: { badges } }))
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Try best-effort ways ABS stores the access token
|
||||
function getAccessToken () {
|
||||
try {
|
||||
if (typeof window === 'undefined') return null
|
||||
const w = window
|
||||
const nuxtStore = w.$nuxt?.$store || null
|
||||
const nuxtState = w.__NUXT__?.state || null
|
||||
|
||||
const tryFromStore = (s) => {
|
||||
if (!s) return null
|
||||
if (s.user?.user?.accessToken) return s.user.user.accessToken
|
||||
if (s.user?.accessToken) return s.user.accessToken
|
||||
if (s.user?.token) return s.user.token
|
||||
return null
|
||||
}
|
||||
|
||||
const tokenFromStore = tryFromStore(nuxtStore) || tryFromStore(nuxtState)
|
||||
const tokenFromStorage =
|
||||
w.localStorage?.getItem('accessToken') ||
|
||||
w.localStorage?.getItem('token') ||
|
||||
null
|
||||
|
||||
return tokenFromStore || tokenFromStorage || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function authHeaders () {
|
||||
const t = getAccessToken()
|
||||
return t ? { Authorization: `Bearer ${t}` } : {}
|
||||
}
|
||||
|
||||
const normalizeProgress = (p) => ({
|
||||
userId: p?.userId || 'guest',
|
||||
counters: p?.counters || {},
|
||||
unlocked: Array.isArray(p?.unlocked) ? p.unlocked : [],
|
||||
history: Array.isArray(p?.history) ? p.history : []
|
||||
})
|
||||
|
||||
async function postComplete (payload) {
|
||||
const data = await fetch('/api/achievements/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload || {})
|
||||
})
|
||||
.then(json)
|
||||
.catch(() => ({}))
|
||||
if (data?.unlockedNow?.length) dispatchUnlocked(data.unlockedNow)
|
||||
return data
|
||||
}
|
||||
|
||||
export default {
|
||||
async getCatalog () {
|
||||
return fetch('/api/achievements/catalog', {
|
||||
credentials: 'include'
|
||||
}).then(json)
|
||||
},
|
||||
|
||||
async getMy () {
|
||||
return fetch('/api/achievements/me', {
|
||||
credentials: 'include',
|
||||
headers: { ...authHeaders() }
|
||||
})
|
||||
.then(json)
|
||||
.then(normalizeProgress)
|
||||
.catch(() => normalizeProgress(null))
|
||||
},
|
||||
|
||||
async complete (payload) {
|
||||
return postComplete(payload)
|
||||
},
|
||||
|
||||
// NEW — convenience wrappers
|
||||
loginPing () {
|
||||
return postComplete({ event: 'login' })
|
||||
},
|
||||
markSearched () {
|
||||
return postComplete({ event: 'search' })
|
||||
},
|
||||
markReadToday () {
|
||||
return postComplete({ event: 'readUploadedToday' })
|
||||
}
|
||||
}
|
||||
BIN
client/static/badges/author_finder.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
client/static/badges/avid.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
client/static/badges/back_to_back.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
client/static/badges/book_downloader.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
client/static/badges/book_uploader.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
client/static/badges/bookworm.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
client/static/badges/centurion.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
client/static/badges/collection_creator.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
client/static/badges/collection_maker.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
client/static/badges/curious.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
client/static/badges/double_dip.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
client/static/badges/excellence-awards.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
client/static/badges/explorer.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
client/static/badges/first_login.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
client/static/badges/first_search.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
client/static/badges/focused_five.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
client/static/badges/hunter.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
client/static/badges/legend.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
client/static/badges/library_creator.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
client/static/badges/library_editor.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
client/static/badges/marathoner.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
client/static/badges/master_queries.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
client/static/badges/monthly_reader.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
client/static/badges/narrator_finder.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
client/static/badges/new_reader.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
client/static/badges/one_day_finish.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
client/static/badges/page_turner.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
client/static/badges/pathfinder.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
client/static/badges/refined_explorer.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
client/static/badges/searcher.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
client/static/badges/seeker.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
client/static/badges/series_starter.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
client/static/badges/streak_3.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
client/static/badges/streak_30.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
client/static/badges/streak_7.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
client/static/badges/tune_weaver.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
client/static/badges/weekly_reader.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
201
server/Server.js
|
|
@ -1,3 +1,4 @@
|
|||
// server/Server.js
|
||||
const Path = require('path')
|
||||
const Sequelize = require('sequelize')
|
||||
const express = require('express')
|
||||
|
|
@ -23,6 +24,7 @@ const SocketAuthority = require('./SocketAuthority')
|
|||
const ApiRouter = require('./routers/ApiRouter')
|
||||
const HlsRouter = require('./routers/HlsRouter')
|
||||
const PublicRouter = require('./routers/PublicRouter')
|
||||
const createAchievementsRouter = require('./routers/achievements') // ✅ single import
|
||||
|
||||
const LogManager = require('./managers/LogManager')
|
||||
const EmailManager = require('./managers/EmailManager')
|
||||
|
|
@ -39,11 +41,44 @@ const BinaryManager = require('./managers/BinaryManager')
|
|||
const ShareManager = require('./managers/ShareManager')
|
||||
const LibraryScanner = require('./scanner/LibraryScanner')
|
||||
|
||||
//Import the main Passport and Express-Session library
|
||||
// Passport / session
|
||||
const passport = require('passport')
|
||||
const expressSession = require('express-session')
|
||||
const MemoryStore = require('./libs/memorystore')
|
||||
|
||||
// 👉 Achievements
|
||||
const AchievementManager = require('./managers/AchievementManager')
|
||||
|
||||
// Small helper for “once per day” user login ping
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const todayKey = () => Math.floor(Date.now() / DAY_MS)
|
||||
function loginAchievementPing (req, _res, next) {
|
||||
try {
|
||||
const user = req.user
|
||||
const userId = user && (user.id || user.userId)
|
||||
if (!userId) return next()
|
||||
|
||||
// Use the session to avoid re-emitting within the same day
|
||||
if (req.session) {
|
||||
const tkey = todayKey()
|
||||
const markerKey = '_absLoginPingDay'
|
||||
if (req.session[markerKey] !== tkey) {
|
||||
req.session[markerKey] = tkey
|
||||
AchievementManager.applyEvent(String(userId), 'userLoggedIn').catch(() => {})
|
||||
}
|
||||
} else {
|
||||
// No session available; still try once (per request)
|
||||
if (!req._absLoginPingSent) {
|
||||
req._absLoginPingSent = true
|
||||
AchievementManager.applyEvent(String(userId), 'userLoggedIn').catch(() => {})
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// never block
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
class Server {
|
||||
constructor(SOURCE, PORT, HOST, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) {
|
||||
this.Port = PORT
|
||||
|
|
@ -57,7 +92,6 @@ class Server {
|
|||
global.AllowCors = process.env.ALLOW_CORS === '1'
|
||||
|
||||
if (process.env.EXP_PROXY_SUPPORT === '1') {
|
||||
// https://github.com/advplyr/audiobookshelf/pull/3754
|
||||
Logger.info(`[Server] Experimental Proxy Support Enabled, SSRF Request Filter was Disabled`)
|
||||
global.DisableSsrfRequestFilter = () => true
|
||||
|
||||
|
|
@ -71,7 +105,6 @@ class Server {
|
|||
url: error.response.headers.location
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
|
@ -88,12 +121,8 @@ class Server {
|
|||
global.PodcastDownloadTimeout = toNumber(process.env.PODCAST_DOWNLOAD_TIMEOUT, 30000)
|
||||
global.MaxFailedEpisodeChecks = toNumber(process.env.MAX_FAILED_EPISODE_CHECKS, 24)
|
||||
|
||||
if (!fs.pathExistsSync(global.ConfigPath)) {
|
||||
fs.mkdirSync(global.ConfigPath)
|
||||
}
|
||||
if (!fs.pathExistsSync(global.MetadataPath)) {
|
||||
fs.mkdirSync(global.MetadataPath)
|
||||
}
|
||||
if (!fs.pathExistsSync(global.ConfigPath)) fs.mkdirSync(global.ConfigPath)
|
||||
if (!fs.pathExistsSync(global.MetadataPath)) fs.mkdirSync(global.MetadataPath)
|
||||
|
||||
this.auth = new Auth()
|
||||
|
||||
|
|
@ -118,15 +147,7 @@ class Server {
|
|||
this.server = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to check if the current request is authenticated
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @param {import('express').NextFunction} next
|
||||
*/
|
||||
authMiddleware(req, res, next) {
|
||||
// ask passportjs if the current request is authenticated
|
||||
this.auth.isAuthenticated(req, res, next)
|
||||
}
|
||||
|
||||
|
|
@ -134,10 +155,6 @@ class Server {
|
|||
LibraryScanner.setCancelLibraryScan(libraryId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize database, backups, logs, rss feeds, cron jobs & watcher
|
||||
* Cleanup stale/invalid data
|
||||
*/
|
||||
async init() {
|
||||
Logger.info('[Server] Init v' + version)
|
||||
Logger.info('[Server] Node.js Version:', process.version)
|
||||
|
|
@ -146,22 +163,14 @@ class Server {
|
|||
|
||||
await this.playbackSessionManager.removeOrphanStreams()
|
||||
|
||||
/**
|
||||
* Docker container ffmpeg/ffprobe binaries are included in the image.
|
||||
* Docker is currently using ffmpeg/ffprobe v6.1 instead of v5.1 so skipping the check
|
||||
* TODO: Support binary check for all sources
|
||||
*/
|
||||
if (global.Source !== 'docker') {
|
||||
await this.binaryManager.init()
|
||||
}
|
||||
|
||||
await Database.init(false)
|
||||
// Create or set JWT secret in token manager
|
||||
await this.auth.tokenManager.initTokenSecret()
|
||||
|
||||
await Logger.logManager.init()
|
||||
|
||||
await this.cleanUserData() // Remove invalid user item progress
|
||||
await this.cleanUserData()
|
||||
await CacheManager.ensureCachePaths()
|
||||
|
||||
await ShareManager.init()
|
||||
|
|
@ -183,9 +192,6 @@ class Server {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for SIGINT and uncaught exceptions
|
||||
*/
|
||||
initProcessEventListeners() {
|
||||
let sigintAlreadyReceived = false
|
||||
process.on('SIGINT', async () => {
|
||||
|
|
@ -200,15 +206,9 @@ class Server {
|
|||
process.exit(0)
|
||||
})
|
||||
|
||||
/**
|
||||
* @see https://nodejs.org/api/process.html#event-uncaughtexceptionmonitor
|
||||
*/
|
||||
process.on('uncaughtExceptionMonitor', async (error, origin) => {
|
||||
await Logger.fatal(`[Server] Uncaught exception origin: ${origin}, error:`, util.format('%O', error))
|
||||
})
|
||||
/**
|
||||
* @see https://nodejs.org/api/process.html#event-unhandledrejection
|
||||
*/
|
||||
process.on('unhandledRejection', async (reason, promise) => {
|
||||
await Logger.fatal('[Server] Unhandled rejection:', reason, '\npromise:', util.format('%O', promise))
|
||||
process.exit(1)
|
||||
|
|
@ -225,75 +225,56 @@ class Server {
|
|||
|
||||
app.use((req, res, next) => {
|
||||
if (!global.ServerSettings.allowIframe) {
|
||||
// Prevent clickjacking by disallowing iframes
|
||||
res.setHeader('Content-Security-Policy', "frame-ancestors 'self'")
|
||||
}
|
||||
|
||||
// Security: Prevent referrer leakage to protect against token exposure
|
||||
// Using 'no-referrer' to completely prevent token leakage in referer headers
|
||||
res.setHeader('Referrer-Policy', 'no-referrer')
|
||||
|
||||
/**
|
||||
* @temporary
|
||||
* This is necessary for the ebook & cover API endpoint in the mobile apps
|
||||
* The mobile app ereader is using fetch api in Capacitor that is currently difficult to switch to native requests
|
||||
* so we have to allow cors for specific origins to the /api/items/:id/ebook endpoint
|
||||
* The cover image is fetched with XMLHttpRequest in the mobile apps to load into a canvas and extract colors
|
||||
* @see https://ionicframework.com/docs/troubleshooting/cors
|
||||
*
|
||||
* Running in development allows cors to allow testing the mobile apps in the browser
|
||||
* or env variable ALLOW_CORS = '1'
|
||||
*/
|
||||
if (global.AllowCors || Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/(ebook|cover)(\/[0-9]+)?/) || global.ServerSettings.allowedOrigins?.length) {
|
||||
if (
|
||||
global.AllowCors ||
|
||||
Logger.isDev ||
|
||||
req.path.match(/\/api\/items\/([a-z0-9-]{36})\/(ebook|cover)(\/[0-9]+)?/) ||
|
||||
global.ServerSettings.allowedOrigins?.length
|
||||
) {
|
||||
const allowedOrigins = ['capacitor://localhost', 'http://localhost', ...(global.ServerSettings.allowedOrigins ? global.ServerSettings.allowedOrigins : [])]
|
||||
if (global.AllowCors || Logger.isDev || allowedOrigins.some((o) => o === req.get('origin'))) {
|
||||
res.header('Access-Control-Allow-Origin', req.get('origin'))
|
||||
res.header('Access-Control-Allow-Methods', 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
|
||||
res.header('Access-Control-Allow-Headers', '*')
|
||||
res.header('Access-Control-Allow-Credentials', true)
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.sendStatus(200)
|
||||
}
|
||||
if (req.method === 'OPTIONS') return res.sendStatus(200)
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
// parse cookies in requests
|
||||
// cookie + session + passport
|
||||
app.use(cookieParser())
|
||||
// enable express-session
|
||||
app.use(
|
||||
expressSession({
|
||||
secret: this.auth.tokenManager.TokenSecret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
// also send the cookie if were are not on https (not every use has https)
|
||||
secure: false
|
||||
},
|
||||
cookie: { secure: false },
|
||||
store: new MemoryStore(86400000, 86400000, 1000)
|
||||
})
|
||||
)
|
||||
// init passport.js
|
||||
app.use(passport.initialize())
|
||||
// register passport in express-session
|
||||
app.use(this.auth.ifAuthNeeded(passport.session()))
|
||||
// config passport.js
|
||||
await this.auth.initPassportJs()
|
||||
|
||||
// 🔔 Global login ping: session users (first authed request of the day)
|
||||
app.use(loginAchievementPing)
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
// if RouterBasePath is set, modify all requests to include the base path
|
||||
// Base path handling
|
||||
app.use((req, res, next) => {
|
||||
const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath)
|
||||
const host = req.get('host')
|
||||
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
||||
const prefix = urlStartsWithRouterBasePath ? global.RouterBasePath : ''
|
||||
req.originalHostPrefix = `${protocol}://${host}${prefix}`
|
||||
if (!urlStartsWithRouterBasePath) {
|
||||
req.url = `${global.RouterBasePath}${req.url}`
|
||||
}
|
||||
if (!urlStartsWithRouterBasePath) req.url = `${global.RouterBasePath}${req.url}`
|
||||
next()
|
||||
})
|
||||
app.use(global.RouterBasePath, router)
|
||||
|
|
@ -310,18 +291,26 @@ class Server {
|
|||
})
|
||||
)
|
||||
router.use(express.urlencoded({ extended: true, limit: '5mb' }))
|
||||
|
||||
// Skip JSON parsing for internal-api routes
|
||||
router.use(/^(?!\/internal-api).*/, express.json({ limit: '10mb' }))
|
||||
|
||||
router.use('/api', this.auth.ifAuthNeeded(this.authMiddleware.bind(this)), this.apiRouter.router)
|
||||
// ✅ Achievements API first (unchanged)
|
||||
router.use('/api/achievements', createAchievementsRouter({ auth: this.auth }))
|
||||
|
||||
// Existing routers
|
||||
// For /api we must place login ping AFTER auth so JWT users also unlock
|
||||
router.use(
|
||||
'/api',
|
||||
this.auth.ifAuthNeeded(this.authMiddleware.bind(this)),
|
||||
loginAchievementPing, // 🔔 ensure JWT logins get recorded
|
||||
this.apiRouter.router
|
||||
)
|
||||
router.use('/hls', this.hlsRouter.router)
|
||||
router.use('/public', this.publicRouter.router)
|
||||
|
||||
// Static folder
|
||||
// Static
|
||||
router.use(express.static(Path.join(global.appRoot, 'static')))
|
||||
|
||||
// RSS Feed temp route
|
||||
// RSS
|
||||
router.get('/feed/:slug', (req, res) => {
|
||||
Logger.info(`[Server] Requesting rss feed ${req.params.slug}`)
|
||||
RssFeedManager.getFeed(req, res)
|
||||
|
|
@ -345,8 +334,6 @@ class Server {
|
|||
this.initializeServer(req, res)
|
||||
})
|
||||
router.get('/status', (req, res) => {
|
||||
// status check for client to see if server has been initialized
|
||||
// server has been initialized if a root user exists
|
||||
const payload = {
|
||||
app: 'audiobookshelf',
|
||||
serverVersion: version,
|
||||
|
|
@ -369,11 +356,10 @@ class Server {
|
|||
|
||||
const ReactClientPath = process.env.REACT_CLIENT_PATH
|
||||
if (!ReactClientPath) {
|
||||
// Static path to generated nuxt
|
||||
const distPath = Path.join(global.appRoot, '/client/dist')
|
||||
router.use(express.static(distPath))
|
||||
|
||||
// Client dynamic routes
|
||||
// SPA fallbacks (add badges)
|
||||
const dynamicRoutes = [
|
||||
'/item/:id',
|
||||
'/author/:id',
|
||||
|
|
@ -395,11 +381,12 @@ class Server {
|
|||
'/config/item-metadata-utils/:id',
|
||||
'/collection/:id',
|
||||
'/playlist/:id',
|
||||
'/share/:slug'
|
||||
'/share/:slug',
|
||||
'/my-badges',
|
||||
'/my-badges/:collectionId'
|
||||
]
|
||||
dynamicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
|
||||
} else {
|
||||
// This is for using the experimental Next.js client
|
||||
Logger.info(`Using React client at ${ReactClientPath}`)
|
||||
const nextPath = Path.join(ReactClientPath, 'node_modules/next')
|
||||
const next = require(nextPath)
|
||||
|
|
@ -424,7 +411,6 @@ class Server {
|
|||
})
|
||||
}
|
||||
|
||||
// Start listening for socket connections
|
||||
SocketAuthority.initialize(this)
|
||||
}
|
||||
|
||||
|
|
@ -435,15 +421,10 @@ class Server {
|
|||
const rootPash = newRoot.password ? await this.auth.localAuthStrategy.hashPassword(newRoot.password) : ''
|
||||
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
|
||||
await Database.createRootUser(rootUsername, rootPash, this.auth)
|
||||
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove user media progress for items that no longer exist & remove seriesHideFrom that no longer exist
|
||||
*/
|
||||
async cleanUserData() {
|
||||
// Get all media progress without an associated media item
|
||||
const mediaProgressToRemove = await Database.mediaProgressModel.findAll({
|
||||
where: {
|
||||
'$podcastEpisode.id$': null,
|
||||
|
|
@ -451,36 +432,27 @@ class Server {
|
|||
},
|
||||
attributes: ['id'],
|
||||
include: [
|
||||
{
|
||||
model: Database.bookModel,
|
||||
attributes: ['id']
|
||||
},
|
||||
{
|
||||
model: Database.podcastEpisodeModel,
|
||||
attributes: ['id']
|
||||
}
|
||||
{ model: Database.bookModel, attributes: ['id'] },
|
||||
{ model: Database.podcastEpisodeModel, attributes: ['id'] }
|
||||
]
|
||||
})
|
||||
if (mediaProgressToRemove.length) {
|
||||
// Remove media progress
|
||||
const mediaProgressRemoved = await Database.mediaProgressModel.destroy({
|
||||
where: {
|
||||
id: {
|
||||
[Sequelize.Op.in]: mediaProgressToRemove.map((mp) => mp.id)
|
||||
}
|
||||
}
|
||||
where: { id: { [Sequelize.Op.in]: mediaProgressToRemove.map((mp) => mp.id) } }
|
||||
})
|
||||
if (mediaProgressRemoved) {
|
||||
Logger.info(`[Server] Removed ${mediaProgressRemoved} media progress for media items that no longer exist in db`)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove series from hide from continue listening that no longer exist
|
||||
try {
|
||||
const users = await Database.sequelize.query(`SELECT u.id, u.username, u.extraData, json_group_array(value) AS seriesIdsToRemove FROM users u, json_each(u.extraData->"seriesHideFromContinueListening") LEFT JOIN series se ON se.id = value WHERE se.id IS NULL GROUP BY u.id;`, {
|
||||
model: Database.userModel,
|
||||
type: Sequelize.QueryTypes.SELECT
|
||||
})
|
||||
const users = await Database.sequelize.query(
|
||||
`SELECT u.id, u.username, u.extraData, json_group_array(value) AS seriesIdsToRemove
|
||||
FROM users u, json_each(u.extraData->"seriesHideFromContinueListening")
|
||||
LEFT JOIN series se ON se.id = value
|
||||
WHERE se.id IS NULL GROUP BY u.id;`,
|
||||
{ model: Database.userModel, type: Sequelize.QueryTypes.SELECT }
|
||||
)
|
||||
for (const user of users) {
|
||||
const extraData = JSON.parse(user.extraData)
|
||||
const existingSeriesIds = extraData.seriesHideFromContinueListening
|
||||
|
|
@ -497,18 +469,19 @@ class Server {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully stop server
|
||||
* Stops watcher and socket server
|
||||
*/
|
||||
async stop() {
|
||||
Logger.info('=== Stopping Server ===')
|
||||
Watcher.close()
|
||||
Logger.info('[Server] Watcher Closed')
|
||||
await SocketAuthority.close()
|
||||
Logger.info('[Server] Closing HTTP Server')
|
||||
await new Promise((resolve) => this.server.close(resolve))
|
||||
Logger.info('[Server] HTTP Server Closed')
|
||||
if (this.server) {
|
||||
await new Promise((resolve) => this.server.close(resolve))
|
||||
Logger.info('[Server] HTTP Server Closed')
|
||||
} else {
|
||||
Logger.info('[Server] HTTP Server not running')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Server
|
||||
|
|
|
|||
111
server/controllers/AchievementController.js
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// server/controllers/AchievementController.js
|
||||
|
||||
const AchievementManager = require('../managers/AchievementManager')
|
||||
|
||||
/**
|
||||
* Controller for Achievement endpoints.
|
||||
* You can wire these handlers from any Express router:
|
||||
*
|
||||
* const ctrl = require('../controllers/AchievementController')
|
||||
* router.get('/catalog', ctrl.catalog)
|
||||
* router.get('/me', auth.jwtAuthCheck, ctrl.me)
|
||||
* router.post('/complete', auth.jwtAuthCheck, express.json(), ctrl.complete)
|
||||
*/
|
||||
module.exports = {
|
||||
/**
|
||||
* GET /api/achievements/catalog
|
||||
* Public — returns the full catalog (collections + badges).
|
||||
*/
|
||||
async catalog(req, res, next) {
|
||||
try {
|
||||
const data = await AchievementManager.getCatalog()
|
||||
res.json(data)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /api/achievements/me
|
||||
* Authenticated — returns the caller's progress.
|
||||
*/
|
||||
async me(req, res, next) {
|
||||
try {
|
||||
const userId = String(req.user?.id || req.user?.userId || '')
|
||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' })
|
||||
|
||||
const progress = await AchievementManager.getUserProgress(userId)
|
||||
res.json(progress)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /api/achievements/complete
|
||||
* Authenticated — applies a user event and returns any newly unlocked badges.
|
||||
* Expects: { event: string, meta?: object }
|
||||
*/
|
||||
async complete(req, res, next) {
|
||||
try {
|
||||
const userId = String(req.user?.id || req.user?.userId || '')
|
||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' })
|
||||
|
||||
const { event, meta } = req.body || {}
|
||||
if (!event) return res.status(400).json({ error: 'Missing event' })
|
||||
|
||||
const result = await AchievementManager.applyEvent(userId, event, meta || {})
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
},
|
||||
|
||||
async getCatalog(req, res, next) {
|
||||
try {
|
||||
const data = await AchievementManager.getCatalog()
|
||||
res.json(data)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
},
|
||||
|
||||
async getUserAchievements(req, res, next) {
|
||||
try {
|
||||
const userId = String(req.user?.id || req.user?.userId || '')
|
||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' })
|
||||
|
||||
const achievements = await AchievementManager.getUserAchievements(userId)
|
||||
res.json(achievements)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
},
|
||||
|
||||
async getMyAchievements(req, res, next) {
|
||||
try {
|
||||
const userId = String(req.user?.id || req.user?.userId || '')
|
||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' })
|
||||
|
||||
const myAchievements = await AchievementManager.getMyAchievements(userId)
|
||||
res.json(myAchievements)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
},
|
||||
|
||||
async completeItem(req, res, next) {
|
||||
try {
|
||||
const userId = String(req.user?.id || req.user?.userId || '')
|
||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' })
|
||||
|
||||
const { itemId } = req.body || {}
|
||||
if (!itemId) return res.status(400).json({ error: 'Missing item ID' })
|
||||
|
||||
const result = await AchievementManager.completeItem(userId, itemId)
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
79
server/data/achievements.catalog.json
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
{
|
||||
"collections": [
|
||||
{
|
||||
"id": "reading_journey",
|
||||
"name": "Reading Journey",
|
||||
"badgeIds": ["new_reader","bookworm","avid","page_turner","marathoner","centurion","legend"]
|
||||
},
|
||||
{
|
||||
"id": "getting_started",
|
||||
"name": "Getting Started",
|
||||
"badgeIds": ["library_creator","library_editor","book_uploader","book_downloader","series_starter","collection_maker","tune_weaver"]
|
||||
},
|
||||
{
|
||||
"id": "adventurers_path",
|
||||
"name": "Adventurer’s Path",
|
||||
"badgeIds": ["searcher","refined_explorer","author_finder","narrator_finder","pathfinder"]
|
||||
},
|
||||
{
|
||||
"id": "daily_devotion",
|
||||
"name": "Daily Devotion",
|
||||
"badgeIds": ["first_login","double_dip","streak_3","streak_7","streak_30"]
|
||||
},
|
||||
{
|
||||
"id": "search_master",
|
||||
"name": "Search Master",
|
||||
"badgeIds": ["first_search","curious","seeker","explorer","hunter","master_queries"]
|
||||
},
|
||||
{
|
||||
"id": "reading_streak",
|
||||
"name": "Reading Streak",
|
||||
"badgeIds": ["one_day_finish","back_to_back","focused_five","weekly_reader","monthly_reader"]
|
||||
}
|
||||
],
|
||||
"badges": {
|
||||
"new_reader": { "id":"new_reader","name":"Turned the First Page","collectionId":"reading_journey","type":"threshold","threshold":1,"blurb":"Finished your first item" },
|
||||
"bookworm": { "id":"bookworm","name":"Lost in Stories","collectionId":"reading_journey","type":"threshold","threshold":5,"blurb":"Finished 5 items" },
|
||||
"avid": { "id":"avid","name":"Can’t Put It Down","collectionId":"reading_journey","type":"threshold","threshold":10,"blurb":"Finished 10 items" },
|
||||
"page_turner": { "id":"page_turner","name":"Flipping Through Worlds","collectionId":"reading_journey","type":"threshold","threshold":25,"blurb":"Finished 25 items" },
|
||||
"marathoner": { "id":"marathoner","name":"Endurance Listener","collectionId":"reading_journey","type":"threshold","threshold":50,"blurb":"Finished 50 items" },
|
||||
"centurion": { "id":"centurion","name":"100 Tales Conquered","collectionId":"reading_journey","type":"threshold","threshold":100,"blurb":"Finished 100 items" },
|
||||
"legend": { "id":"legend","name":"Legend of the Library","collectionId":"reading_journey","type":"threshold","threshold":250,"blurb":"Finished 250 items" },
|
||||
|
||||
"library_creator": { "id":"library_creator","name":"Builder of Shelves","collectionId":"getting_started","type":"first","blurb":"Created your first library" },
|
||||
"library_editor": { "id":"library_editor","name":"Shelf Organizer","collectionId":"getting_started","type":"first","blurb":"Edited a library" },
|
||||
"book_uploader": { "id":"book_uploader","name":"First Contribution","collectionId":"getting_started","type":"first","blurb":"Uploaded your first book" },
|
||||
"book_downloader": { "id":"book_downloader","name":"Pocket Librarian","collectionId":"getting_started","type":"first","blurb":"Downloaded a book" },
|
||||
|
||||
|
||||
"series_starter": { "id":"series_starter","name":"Saga Initiator","collectionId":"getting_started","type":"first","blurb":"Deleted your first uploaded file" },
|
||||
|
||||
"collection_maker": { "id":"collection_maker","name":"Curator in Training","collectionId":"getting_started","type":"first","blurb":"Created your first collection/playlist" },
|
||||
"tune_weaver": { "id":"tune_weaver","name":"Tune Weaver","collectionId":"getting_started","type":"first","blurb":"Created your first playlist" },
|
||||
|
||||
"searcher": { "id":"searcher","name":"Seeker of Stories","collectionId":"adventurers_path","type":"first","blurb":"Performed your first search" },
|
||||
"refined_explorer": { "id":"refined_explorer","name":"Refined Explorer","collectionId":"adventurers_path","type":"first","blurb":"Visited the upload page" },
|
||||
"author_finder": { "id":"author_finder","name":"Author Discoverer","collectionId":"adventurers_path","type":"first","blurb":"Visited author view" },
|
||||
"narrator_finder": { "id":"narrator_finder","name":"Voice Hunter","collectionId":"adventurers_path","type":"first","blurb":"Visited narrator view" },
|
||||
"pathfinder": { "id":"pathfinder","name":"Pathfinder","collectionId":"adventurers_path","type":"first","blurb":"Visited settings" },
|
||||
|
||||
"first_login": { "id":"first_login","name":"First Login","collectionId":"daily_devotion","type":"first","blurb":"Logged in for the first time" },
|
||||
"double_dip": { "id":"double_dip","name":"Double Dip","collectionId":"daily_devotion","type":"first","blurb":"Logged in twice in the same day" },
|
||||
"streak_3": { "id":"streak_3","name":"3-Day Streak","collectionId":"daily_devotion","type":"threshold","threshold":3,"blurb":"Logged in 3 days in a row" },
|
||||
"streak_7": { "id":"streak_7","name":"Weekly Streak","collectionId":"daily_devotion","type":"threshold","threshold":7,"blurb":"Logged in 7 days in a row" },
|
||||
"streak_30": { "id":"streak_30","name":"Monthly Streak","collectionId":"daily_devotion","type":"threshold","threshold":30,"blurb":"Logged in 30 days in a row" },
|
||||
|
||||
"first_search": { "id":"first_search","name":"First Search","collectionId":"search_master","type":"threshold","threshold":1,"blurb":"Searched for 1 item" },
|
||||
"curious": { "id":"curious","name":"Curious","collectionId":"search_master","type":"threshold","threshold":3,"blurb":"Searched for 3 items" },
|
||||
"seeker": { "id":"seeker","name":"Seeker","collectionId":"search_master","type":"threshold","threshold":5,"blurb":"Searched for 5 items" },
|
||||
"explorer": { "id":"explorer","name":"Explorer","collectionId":"search_master","type":"threshold","threshold":10,"blurb":"Searched for 10 items" },
|
||||
"hunter": { "id":"hunter","name":"Hunter","collectionId":"search_master","type":"threshold","threshold":50,"blurb":"Searched for 50 items" },
|
||||
"master_queries": { "id":"master_queries","name":"Master of Queries","collectionId":"search_master","type":"threshold","threshold":100,"blurb":"Searched for 100 items" },
|
||||
|
||||
"one_day_finish": { "id":"one_day_finish","name":"Day One Reader","collectionId":"reading_streak","type":"first","blurb":"Read an uploaded book today" },
|
||||
"back_to_back": { "id":"back_to_back","name":"Back-to-Back","collectionId":"reading_streak","type":"threshold","threshold":2,"blurb":"Read uploaded books 2 days in a row" },
|
||||
"focused_five": { "id":"focused_five","name":"Focused Five","collectionId":"reading_streak","type":"threshold","threshold":5,"blurb":"Read uploaded books 5 days in a row" },
|
||||
"weekly_reader": { "id":"weekly_reader","name":"Weekly Reader","collectionId":"reading_streak","type":"threshold","threshold":7,"blurb":"Read uploaded books 7 days in a row" },
|
||||
"monthly_reader": { "id":"monthly_reader","name":"Monthly Reader","collectionId":"reading_streak","type":"threshold","threshold":30,"blurb":"Read uploaded books 30 days in a row" }
|
||||
}
|
||||
}
|
||||
407
server/managers/AchievementManager.js
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
// server/managers/AchievementManager.js
|
||||
const Path = require('path')
|
||||
const Sequelize = require('sequelize')
|
||||
const fsx = require('../libs/fsExtra')
|
||||
const Database = require('../Database')
|
||||
const Store = require('../utils/userProgressStore')
|
||||
|
||||
const CATALOG_PATH = Path.join(__dirname, '..', 'data', 'achievements.catalog.json')
|
||||
|
||||
let _catalogCache = null
|
||||
|
||||
async function loadCatalog () {
|
||||
if (_catalogCache) return _catalogCache
|
||||
const raw = await fsx.readFile(CATALOG_PATH, 'utf8')
|
||||
_catalogCache = JSON.parse(raw)
|
||||
return _catalogCache
|
||||
}
|
||||
|
||||
// === helpers ================================================================
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const dayKey = (ts = Date.now()) => Math.floor(ts / DAY_MS)
|
||||
const toDbUserId = (userId) => {
|
||||
const n = Number(userId)
|
||||
return Number.isFinite(n) ? n : userId
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// a clean default shape persisted per user
|
||||
function emptyProgress (userId) {
|
||||
return {
|
||||
userId,
|
||||
counters: {
|
||||
// thresholds (unique finishes)
|
||||
finishedUniqueIds: [],
|
||||
finishedUniqueCount: 0,
|
||||
finishedCount: 0,
|
||||
|
||||
// Search Master
|
||||
searchCount: 0,
|
||||
|
||||
// first-time flags
|
||||
createdLibrary: false,
|
||||
editedLibrary: false,
|
||||
uploadedBook: false,
|
||||
downloadedBook: false,
|
||||
createdSeries: false,
|
||||
createdCollection: false,
|
||||
createdPlaylist: false,
|
||||
performedSearch: false,
|
||||
visitedUpload: false,
|
||||
openedAuthor: false,
|
||||
openedNarrator: false,
|
||||
visitedSettings: false,
|
||||
|
||||
// 🔸 NEW: first time deleting an uploaded file
|
||||
deletedFirstFile: false,
|
||||
|
||||
// 🔸 NEW: fallback detector storage
|
||||
lastSeenBookCount: null,
|
||||
|
||||
// Daily Devotion
|
||||
totalLogins: 0,
|
||||
lastLoginKey: null,
|
||||
todayLoginCount: 0,
|
||||
loginStreak: 0,
|
||||
|
||||
// Reading Streak
|
||||
lastFinishedKey: null,
|
||||
readingStreak: 0,
|
||||
finishedWithinDay: false
|
||||
},
|
||||
unlocked: [],
|
||||
history: [],
|
||||
seenPopups: []
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWithCatalog (progress, catalog) {
|
||||
progress.counters = progress.counters || {}
|
||||
if (!Array.isArray(progress.counters.finishedUniqueIds)) progress.counters.finishedUniqueIds = []
|
||||
if (typeof progress.counters.finishedUniqueCount !== 'number') progress.counters.finishedUniqueCount = 0
|
||||
|
||||
const valid = new Set(Object.keys(catalog.badges || {}))
|
||||
progress.unlocked = Array.isArray(progress.unlocked) ? progress.unlocked.filter(id => valid.has(id)) : []
|
||||
progress.unlocked = [...new Set(progress.unlocked)]
|
||||
progress.history = Array.isArray(progress.history) ? progress.history.filter(h => h && valid.has(h.badgeId)) : []
|
||||
return progress
|
||||
}
|
||||
|
||||
// thresholds
|
||||
function readingThresholds () {
|
||||
return [
|
||||
{ id: 'new_reader', threshold: 1 },
|
||||
{ id: 'bookworm', threshold: 5 },
|
||||
{ id: 'avid', threshold: 10 },
|
||||
{ id: 'page_turner', threshold: 25 },
|
||||
{ id: 'marathoner', threshold: 50 },
|
||||
{ id: 'centurion', threshold: 100 },
|
||||
{ id: 'legend', threshold: 250 }
|
||||
]
|
||||
}
|
||||
function searchThresholds () {
|
||||
return [
|
||||
{ id: 'first_search', threshold: 1 },
|
||||
{ id: 'curious', threshold: 3 },
|
||||
{ id: 'seeker', threshold: 5 },
|
||||
{ id: 'explorer', threshold: 10 },
|
||||
{ id: 'hunter', threshold: 50 },
|
||||
{ id: 'master_queries', threshold: 100 }
|
||||
]
|
||||
}
|
||||
|
||||
function computeUnlockedFresh (progress) {
|
||||
const out = new Set()
|
||||
|
||||
// Reading Journey (by UNIQUE finished items)
|
||||
const fc = Number(progress.counters.finishedUniqueCount || 0)
|
||||
for (const t of readingThresholds()) if (fc >= t.threshold) out.add(t.id)
|
||||
|
||||
// Search Master
|
||||
const sc = Number(progress.counters.searchCount || 0)
|
||||
for (const t of searchThresholds()) if (sc >= t.threshold) out.add(t.id)
|
||||
|
||||
// Getting Started + playlist + 🔸deletedFirstFile → Saga Initiator
|
||||
const started = [
|
||||
['createdLibrary', 'library_creator'],
|
||||
['editedLibrary', 'library_editor'],
|
||||
['uploadedBook', 'book_uploader'],
|
||||
['downloadedBook', 'book_downloader'],
|
||||
// 🔸 re-purpose the existing badge id for the new action
|
||||
['deletedFirstFile', 'series_starter'],
|
||||
['createdCollection', 'collection_maker'],
|
||||
['createdPlaylist', 'tune_weaver']
|
||||
]
|
||||
for (const [flag, badgeId] of started) if (progress.counters[flag]) out.add(badgeId)
|
||||
|
||||
// Adventurer’s Path
|
||||
const explore = [
|
||||
['performedSearch', 'searcher'],
|
||||
['visitedUpload', 'refined_explorer'],
|
||||
['openedAuthor', 'author_finder'],
|
||||
['openedNarrator', 'narrator_finder'],
|
||||
['visitedSettings', 'pathfinder']
|
||||
]
|
||||
for (const [flag, badgeId] of explore) if (progress.counters[flag]) out.add(badgeId)
|
||||
|
||||
// Daily Devotion
|
||||
const ls = Number(progress.counters.loginStreak || 0)
|
||||
if (Number(progress.counters.totalLogins || 0) >= 1) out.add('first_login')
|
||||
if (Number(progress.counters.todayLoginCount || 0) >= 2) out.add('double_dip')
|
||||
if (ls >= 3) out.add('streak_3')
|
||||
if (ls >= 7) out.add('streak_7')
|
||||
if (ls >= 30) out.add('streak_30')
|
||||
|
||||
// Reading Streak
|
||||
const rs = Number(progress.counters.readingStreak || 0)
|
||||
if (progress.counters.finishedWithinDay) out.add('one_day_finish')
|
||||
if (rs >= 2) out.add('back_to_back')
|
||||
if (rs >= 5) out.add('focused_five')
|
||||
if (rs >= 7) out.add('weekly_reader')
|
||||
if (rs >= 30) out.add('monthly_reader')
|
||||
|
||||
return [...out]
|
||||
}
|
||||
|
||||
// robust DISTINCT finished ids from DB, with fallbacks
|
||||
async function getDistinctFinishedIds (userId) {
|
||||
const uid = toDbUserId(userId)
|
||||
const Op = Sequelize.Op
|
||||
|
||||
try {
|
||||
const rows = await Database.mediaProgressModel.findAll({
|
||||
where: { userId: uid, finishedAt: { [Op.ne]: null } },
|
||||
attributes: [[Sequelize.fn('DISTINCT', Sequelize.col('libraryItemId')), 'libraryItemId']],
|
||||
raw: true
|
||||
})
|
||||
const ids = rows.map(r => String(r.libraryItemId)).filter(Boolean)
|
||||
if (ids.length) return [...new Set(ids)]
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const rows = await Database.mediaProgressModel.findAll({
|
||||
where: { userId: uid, isFinished: 1 },
|
||||
attributes: [[Sequelize.fn('DISTINCT', Sequelize.col('libraryItemId')), 'libraryItemId']],
|
||||
raw: true
|
||||
})
|
||||
const ids = rows.map(r => String(r.libraryItemId)).filter(Boolean)
|
||||
if (ids.length) return [...new Set(ids)]
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const rows = await Database.mediaProgressModel.findAll({
|
||||
where: { userId: uid },
|
||||
attributes: ['libraryItemId', 'finishedAt', 'isFinished', 'currentTime', 'duration'],
|
||||
raw: true
|
||||
})
|
||||
const ids = []
|
||||
for (const r of rows) {
|
||||
const id = r && r.libraryItemId != null ? String(r.libraryItemId) : null
|
||||
if (!id) continue
|
||||
const finishedAt = r.finishedAt != null
|
||||
const isFinished = Number(r.isFinished || 0) === 1
|
||||
const ratioOk = Number(r.duration || 0) > 0 && Number(r.currentTime || 0) / Number(r.duration) >= 0.98
|
||||
if (finishedAt || isFinished || ratioOk) ids.push(id)
|
||||
}
|
||||
if (ids.length) return [...new Set(ids)]
|
||||
} catch {}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
async function finishedWithinOneDay (userId, itemId = null) {
|
||||
const uid = toDbUserId(userId)
|
||||
const Op = Sequelize.Op
|
||||
try {
|
||||
const where = itemId
|
||||
? { userId: uid, libraryItemId: itemId }
|
||||
: { userId: uid, finishedAt: { [Op.ne]: null } }
|
||||
const mp = await Database.mediaProgressModel.findOne({
|
||||
where,
|
||||
order: itemId ? [['updatedAt', 'DESC']] : [['finishedAt', 'DESC']]
|
||||
})
|
||||
if (!mp) return false
|
||||
const started = mp.createdAt ? new Date(mp.createdAt).getTime() : null
|
||||
const finished = mp.finishedAt ? new Date(mp.finishedAt).getTime() : null
|
||||
if (!started || !finished) return false
|
||||
return (finished - started) <= DAY_MS
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function syncFromDb (userId, progress) {
|
||||
let idsFromDb = []
|
||||
try { idsFromDb = await getDistinctFinishedIds(userId) } catch {}
|
||||
|
||||
const fallbackIds = Array.isArray(progress.counters.finishedUniqueIds)
|
||||
? [...new Set(progress.counters.finishedUniqueIds)]
|
||||
: []
|
||||
|
||||
const finalIds = idsFromDb.length ? idsFromDb : fallbackIds
|
||||
progress.counters.finishedUniqueIds = finalIds
|
||||
progress.counters.finishedUniqueCount = finalIds.length
|
||||
progress.counters.finishedCount = finalIds.length
|
||||
|
||||
// best-effort backfills
|
||||
try {
|
||||
const libCount = await Database.libraryModel.count()
|
||||
if (libCount > 0) progress.counters.createdLibrary = true
|
||||
} catch {}
|
||||
try {
|
||||
const bookCount = await Database.bookModel.count()
|
||||
if (bookCount > 0) progress.counters.uploadedBook = true
|
||||
} catch {}
|
||||
try {
|
||||
if (!progress.counters.finishedWithinDay) {
|
||||
progress.counters.finishedWithinDay = await finishedWithinOneDay(userId, null)
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// 🔸 NEW: fallback detection for a deletion
|
||||
try {
|
||||
const currentBooks = await Database.bookModel.count()
|
||||
const lastSeen = progress.counters.lastSeenBookCount
|
||||
progress.counters.lastSeenBookCount = currentBooks
|
||||
if (
|
||||
progress.counters.uploadedBook && // user has ever uploaded
|
||||
lastSeen !== null &&
|
||||
currentBooks < lastSeen && // total down → something was deleted
|
||||
!progress.counters.deletedFirstFile // only set once
|
||||
) {
|
||||
progress.counters.deletedFirstFile = true
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const before = new Set(progress.unlocked)
|
||||
progress.unlocked = computeUnlockedFresh(progress)
|
||||
const now = new Date().toISOString()
|
||||
for (const id of progress.unlocked) if (!before.has(id)) {
|
||||
progress.history.push({ badgeId: id, unlockedAt: now })
|
||||
}
|
||||
return progress
|
||||
}
|
||||
|
||||
async function getUserProgress (userId, opts = {}) {
|
||||
const catalog = await loadCatalog()
|
||||
let progress = (await Store.read(userId)) || emptyProgress(userId)
|
||||
progress = normalizeWithCatalog(progress, catalog)
|
||||
|
||||
if (opts.syncFromDb) {
|
||||
await syncFromDb(userId, progress)
|
||||
progress = normalizeWithCatalog(progress, catalog)
|
||||
await Store.write(userId, progress)
|
||||
}
|
||||
return progress
|
||||
}
|
||||
|
||||
function updateLoginStreak (c) {
|
||||
const today = dayKey()
|
||||
if (c.lastLoginKey === today) {
|
||||
c.todayLoginCount = (c.todayLoginCount || 0) + 1
|
||||
return
|
||||
}
|
||||
const yesterday = today - 1
|
||||
if (c.lastLoginKey === yesterday) c.loginStreak = (c.loginStreak || 0) + 1
|
||||
else c.loginStreak = 1
|
||||
c.lastLoginKey = today
|
||||
c.todayLoginCount = 1
|
||||
c.totalLogins = (c.totalLogins || 0) + 1
|
||||
}
|
||||
|
||||
function updateReadingStreakOnFinish (c) {
|
||||
const today = dayKey()
|
||||
if (c.lastFinishedKey === today) {
|
||||
// already counted today
|
||||
} else if (c.lastFinishedKey === today - 1) {
|
||||
c.readingStreak = (c.readingStreak || 0) + 1
|
||||
c.lastFinishedKey = today
|
||||
} else {
|
||||
c.readingStreak = 1
|
||||
c.lastFinishedKey = today
|
||||
}
|
||||
}
|
||||
|
||||
async function applyEvent (userId, event, meta = {}) {
|
||||
const catalog = await loadCatalog()
|
||||
const progress = await getUserProgress(userId)
|
||||
|
||||
switch (event) {
|
||||
case 'itemFinished': {
|
||||
updateReadingStreakOnFinish(progress.counters)
|
||||
const rawId = meta.itemId != null ? meta.itemId : meta.libraryItemId
|
||||
const itemId = rawId != null ? String(rawId) : null
|
||||
if (itemId) {
|
||||
const set = new Set(progress.counters.finishedUniqueIds || [])
|
||||
set.add(itemId)
|
||||
progress.counters.finishedUniqueIds = [...set]
|
||||
}
|
||||
let ids = []
|
||||
try { ids = await getDistinctFinishedIds(userId) } catch {}
|
||||
if (!ids.length && progress.counters.finishedUniqueIds?.length) {
|
||||
ids = [...new Set(progress.counters.finishedUniqueIds)]
|
||||
}
|
||||
progress.counters.finishedUniqueIds = ids
|
||||
progress.counters.finishedUniqueCount = ids.length
|
||||
progress.counters.finishedCount = ids.length
|
||||
|
||||
try {
|
||||
const withinDay = await finishedWithinOneDay(userId, itemId || null)
|
||||
if (withinDay) progress.counters.finishedWithinDay = true
|
||||
} catch {}
|
||||
break
|
||||
}
|
||||
|
||||
// first-time/flags
|
||||
case 'createdLibrary': progress.counters.createdLibrary = true; break
|
||||
case 'editedLibrary': progress.counters.editedLibrary = true; break
|
||||
case 'uploadedBook': progress.counters.uploadedBook = true; break
|
||||
case 'downloadedBook': progress.counters.downloadedBook = true; break
|
||||
case 'createdSeries': progress.counters.createdSeries = true; break
|
||||
case 'createdCollection': progress.counters.createdCollection = true; break
|
||||
case 'createdPlaylist': progress.counters.createdPlaylist = true; break
|
||||
case 'openedAuthor': progress.counters.openedAuthor = true; break
|
||||
case 'openedNarrator': progress.counters.openedNarrator = true; break
|
||||
case 'visitedSettings': progress.counters.visitedSettings = true; break
|
||||
case 'visitedUpload': progress.counters.visitedUpload = true; break
|
||||
|
||||
// 🔸 NEW: first file deletion
|
||||
case 'deletedFile':
|
||||
if (!progress.counters.deletedFirstFile) progress.counters.deletedFirstFile = true
|
||||
break
|
||||
|
||||
// searches
|
||||
case 'performedSearch':
|
||||
progress.counters.performedSearch = true
|
||||
progress.counters.searchCount = (progress.counters.searchCount || 0) + 1
|
||||
break
|
||||
|
||||
// login
|
||||
case 'userLoggedIn':
|
||||
updateLoginStreak(progress.counters)
|
||||
break
|
||||
|
||||
default: break
|
||||
}
|
||||
|
||||
const before = new Set(progress.unlocked)
|
||||
progress.unlocked = computeUnlockedFresh(progress)
|
||||
|
||||
const unlockedNow = []
|
||||
const now = new Date().toISOString()
|
||||
for (const id of progress.unlocked) {
|
||||
if (!before.has(id)) {
|
||||
unlockedNow.push(catalog.badges?.[id] || { id })
|
||||
progress.history.push({ badgeId: id, unlockedAt: now })
|
||||
}
|
||||
}
|
||||
|
||||
await Store.write(userId, progress)
|
||||
return { unlockedNow, progress }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getCatalog: loadCatalog,
|
||||
getUserProgress,
|
||||
applyEvent
|
||||
}
|
||||
89
server/middleware/achievementAutoTracker.js
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// server/middleware/achievementAutoTracker.js
|
||||
// Best-effort, low-overhead server middleware that translates common API
|
||||
// requests into achievement events. It listens for the final status code so we
|
||||
// only record events on successful requests.
|
||||
|
||||
const AchievementManager = require('../managers/AchievementManager')
|
||||
|
||||
// normalize to YYYY-MM-DD
|
||||
const today = () => new Date().toISOString().slice(0, 10)
|
||||
|
||||
module.exports = function achievementAutoTracker () {
|
||||
return (req, res, next) => {
|
||||
// We’ll run *after* the auth middleware inside the /api chain so req.user
|
||||
// is populated (either session or JWT).
|
||||
const userId = req.user && (req.user.id || req.user.userId)
|
||||
? String(req.user.id || req.user.userId)
|
||||
: null
|
||||
|
||||
const method = req.method || 'GET'
|
||||
const path = req.path || ''
|
||||
const q = req.query || {}
|
||||
|
||||
const fire = (event) => {
|
||||
if (!userId) return
|
||||
AchievementManager.applyEvent(userId, event).catch(() => {})
|
||||
}
|
||||
|
||||
// Mark a login once per day on the first authed API hit we see.
|
||||
if (userId) {
|
||||
const t = today()
|
||||
if (!req.session) req.session = {}
|
||||
if (req.session.__achLoginDate !== t) {
|
||||
req.session.__achLoginDate = t
|
||||
fire('login')
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- SEARCH + FILTERS --------------------
|
||||
// Explicit search endpoint
|
||||
if (method === 'GET' && /^\/api\/libraries\/[^/]+\/search\b/.test(path)) {
|
||||
fire('performedSearch')
|
||||
}
|
||||
|
||||
// Filters on listing endpoints (heuristic: presence of filter-ish query keys)
|
||||
if (method === 'GET' && /^\/api\/libraries\/[^/]+\//.test(path)) {
|
||||
const keysStr = Object.keys(q).join(',').toLowerCase()
|
||||
if (keysStr && /(filter|filters|sort|order|tag|genre|author|narrator|series|collection|min|max|asc|desc)/.test(keysStr)) {
|
||||
fire('appliedFilter')
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- LIBRARIES ---------------------------
|
||||
// Created library
|
||||
if (userId && method === 'POST' && /^\/api\/libraries\b/.test(path)) {
|
||||
res.on('finish', () => { if (res.statusCode < 400) fire('createdLibrary') })
|
||||
}
|
||||
// Edited library (PUT or PATCH /api/libraries/:id)
|
||||
if (userId && (method === 'PUT' || method === 'PATCH') && /^\/api\/libraries\/[^/]+$/.test(path)) {
|
||||
res.on('finish', () => { if (res.statusCode < 400) fire('editedLibrary') })
|
||||
}
|
||||
|
||||
// -------------------- COLLECTIONS / PLAYLISTS -------------
|
||||
// Created collection
|
||||
if (userId && method === 'POST' && /^\/api\/collections?\b/.test(path)) {
|
||||
res.on('finish', () => { if (res.statusCode < 400) fire('createdCollection') })
|
||||
}
|
||||
// Created playlist (covers /api/playlists or /api/playlist)
|
||||
if (userId && method === 'POST' && /^\/api\/playlists?\b/.test(path)) {
|
||||
res.on('finish', () => { if (res.statusCode < 400) fire('createdPlaylist') })
|
||||
}
|
||||
|
||||
// -------------------- BOOK DOWNLOADS ----------------------
|
||||
// ABS uses a few different download/serve endpoints. We fire when it looks
|
||||
// like the user fetched a file for an item.
|
||||
if (userId && method === 'GET') {
|
||||
const isItemDownload =
|
||||
/^\/api\/items\/[a-z0-9-]{36}\//.test(path) &&
|
||||
/(download|ebook|file|audio|stream)/i.test(path)
|
||||
|
||||
const isGenericDownload = /\/download\b/i.test(path)
|
||||
|
||||
if (isItemDownload || isGenericDownload) {
|
||||
res.on('finish', () => { if (res.statusCode < 400) fire('downloadedBook') })
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
50
server/middleware/sessionOrJwt.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// server/middleware/sessionOrJwt.js
|
||||
/**
|
||||
* Accept either an existing session user (passport session)
|
||||
* or a JWT bearer token (API). Returns 401 only if neither is present.
|
||||
*
|
||||
* 👉 Enhancement:
|
||||
* When a user is authenticated, we emit the "userLoggedIn" achievement
|
||||
* event in a fire-and-forget way so "First Login" (and streaks) unlock
|
||||
* immediately for brand-new accounts too.
|
||||
*/
|
||||
const AchievementManager = require('../managers/AchievementManager')
|
||||
|
||||
function fireLoginEvent(req) {
|
||||
try {
|
||||
if (req._absLoginEventSent) return
|
||||
const user = req.user
|
||||
const uid = user && (user.id || user.userId)
|
||||
if (!uid) return
|
||||
|
||||
req._absLoginEventSent = true
|
||||
// Do not block the request if achievements write fails
|
||||
AchievementManager.applyEvent(String(uid), 'userLoggedIn').catch(() => {})
|
||||
} catch (_) {
|
||||
// swallow — achievements must never break auth
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = (auth) => {
|
||||
if (!auth || typeof auth.isAuthenticated !== 'function') {
|
||||
throw new Error('[sessionOrJwt] auth.isAuthenticated missing')
|
||||
}
|
||||
|
||||
return (req, res, next) => {
|
||||
// Session user already present?
|
||||
if (req.user && (req.user.id || req.user.userId)) {
|
||||
fireLoginEvent(req)
|
||||
return next()
|
||||
}
|
||||
|
||||
// Try JWT auth (no session). Use custom callback so we can continue.
|
||||
auth.isAuthenticated(req, res, (err) => {
|
||||
if (err) return next(err)
|
||||
if (req.user && (req.user.id || req.user.userId)) {
|
||||
fireLoginEvent(req)
|
||||
return next()
|
||||
}
|
||||
return res.status(401).json({ error: 'Unauthorized' })
|
||||
})
|
||||
}
|
||||
}
|
||||
41
server/middleware/trackDeletion.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// server/middleware/trackDeletion.js
|
||||
//
|
||||
// Listens for successful DELETE/“delete”/“remove” API calls that target items/files,
|
||||
// and awards the “deleted first file” achievement exactly once for the user.
|
||||
//
|
||||
|
||||
const AchievementManager = require('../managers/AchievementManager')
|
||||
|
||||
module.exports = function trackDeletion () {
|
||||
// Any API route that looks like it deletes items/files/books etc.
|
||||
const looksLikeDeletePath = (p) => {
|
||||
if (!p) return false
|
||||
const path = String(p)
|
||||
const hasDeleteVerb = /\/(delete|remove)(\/|$)/i.test(path)
|
||||
const deleteMethod = /\/api\//i.test(path) && (
|
||||
hasDeleteVerb || /\/(items?|books?|library-items?|uploads?|files?)\//i.test(path)
|
||||
)
|
||||
return deleteMethod
|
||||
}
|
||||
|
||||
return function (req, res, next) {
|
||||
// We’ll track a successful DELETE method OR POSTs that hit “/delete|/remove”
|
||||
const shouldWatch =
|
||||
(req.method === 'DELETE' && looksLikeDeletePath(req.originalUrl || req.url)) ||
|
||||
(['POST', 'PUT', 'PATCH'].includes(req.method) && /\/(delete|remove)(\/|$)/i.test(req.originalUrl || req.url))
|
||||
|
||||
if (!shouldWatch) return next()
|
||||
|
||||
const userId = String(req.user?.id || req.user?.userId || req.session?.userId || '')
|
||||
// Only attach a listener; do not block the request handler
|
||||
res.on('finish', () => {
|
||||
if (!userId) return
|
||||
// Only award if the operation actually succeeded
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
AchievementManager.applyEvent(userId, 'deletedFile').catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
41
server/routers/achievements.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// server/routers/achievements.js
|
||||
const express = require('express')
|
||||
const AchievementManager = require('../managers/AchievementManager')
|
||||
const sessionOrJwt = require('../middleware/sessionOrJwt')
|
||||
|
||||
module.exports = ({ auth } = {}) => {
|
||||
if (!auth) throw new Error('[achievements router] Missing auth')
|
||||
|
||||
const router = express.Router()
|
||||
const requireUser = sessionOrJwt(auth)
|
||||
|
||||
// PUBLIC — catalog
|
||||
router.get('/catalog', async (req, res, next) => {
|
||||
try {
|
||||
const data = await AchievementManager.getCatalog()
|
||||
res.json(data)
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// Needs user (session OR JWT) — also backfill/sync counts from DB
|
||||
router.get('/me', requireUser, async (req, res, next) => {
|
||||
try {
|
||||
const userId = String(req.user?.id || req.user?.userId || req.session?.userId)
|
||||
const progress = await AchievementManager.getUserProgress(userId, { syncFromDb: true })
|
||||
res.json(progress)
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// Mark an achievement-related event as completed
|
||||
router.post('/complete', requireUser, express.json(), async (req, res, next) => {
|
||||
try {
|
||||
const userId = String(req.user?.id || req.user?.userId || req.session?.userId)
|
||||
const { event, meta } = req.body || {}
|
||||
if (!event) return res.status(400).json({ error: 'Missing event' })
|
||||
const result = await AchievementManager.applyEvent(userId, event, meta || {})
|
||||
res.json(result)
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
55
server/utils/userProgressStore.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// server/utils/userProgressStore.js
|
||||
const Path = require('path')
|
||||
const fsx = require('../libs/fsExtra')
|
||||
|
||||
function dir () {
|
||||
// Persist under MetadataPath so it survives restarts/backups similar to other ABS data
|
||||
const root = global.MetadataPath || Path.join(process.cwd(), '.metadata')
|
||||
const p = Path.join(root, 'achievements')
|
||||
if (!fsx.pathExistsSync(p)) fsx.mkdirpSync(p)
|
||||
return p
|
||||
}
|
||||
|
||||
function file (userId) {
|
||||
return Path.join(dir(), `${userId}.json`)
|
||||
}
|
||||
|
||||
async function read (userId) {
|
||||
const f = file(userId)
|
||||
if (!fsx.pathExistsSync(f)) return null
|
||||
try {
|
||||
return JSON.parse(await fsx.readFile(f, 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function write (userId, data) {
|
||||
const f = file(userId)
|
||||
const payload = JSON.stringify(data, null, 2)
|
||||
|
||||
// Your fs-extra wrapper doesn’t expose writeFileAtomic.
|
||||
// Use a safe fallback; prefer atomic (tmp + rename) when possible.
|
||||
try {
|
||||
if (typeof fsx.writeFileAtomic === 'function') {
|
||||
await fsx.writeFileAtomic(f, payload, 'utf8')
|
||||
} else {
|
||||
const tmp = f + '.tmp'
|
||||
await fsx.writeFile(tmp, payload, 'utf8')
|
||||
// Some wrappers expose rename, some move; fall back to writeFile
|
||||
if (typeof fsx.rename === 'function') {
|
||||
await fsx.rename(tmp, f)
|
||||
} else if (typeof fsx.move === 'function') {
|
||||
await fsx.move(tmp, f, { overwrite: true })
|
||||
} else {
|
||||
// last-resort non-atomic
|
||||
await fsx.writeFile(f, payload, 'utf8')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-atomic final fallback to guarantee progress is saved
|
||||
await fsx.writeFile(f, payload, 'utf8')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { read, write }
|
||||