From 100e5c3a871c5b91513c367b0b366aa75f106476 Mon Sep 17 00:00:00 2001 From: HayemR Date: Tue, 26 May 2026 09:07:16 +0700 Subject: [PATCH] database integration --- .env.example | 3 + .gitea/workflows/config.yaml | 47 ++++++++ README.md | 27 +++++ index.html | 2 +- src/App.vue | 27 +++++ src/api.js | 92 +++++++++++++++ src/pages/Account.vue | 183 +++++++++++++++++++++++++++++ src/pages/Home.vue | 102 ---------------- src/pages/Login.vue | 146 +++++++++++++++++++++++ src/pages/Result.vue | 53 +++++++++ src/pages/Signup.vue | 156 ++++++++++++++++++++++++ src/plugins/router.js | 30 ++++- src/stores/authStore.js | 151 ++++++++++++++++++++++++ tools/db/install-db-setup.sh | 44 +++++++ tools/db/photobooth-db.env.example | 7 ++ tools/db/photobooth-db.service | 14 +++ tools/db/photobooth-db.timer | 9 ++ tools/db/setup-db.sh | 83 +++++++++++++ 18 files changed, 1068 insertions(+), 108 deletions(-) create mode 100644 .env.example create mode 100644 src/api.js create mode 100644 src/pages/Account.vue create mode 100644 src/pages/Login.vue create mode 100644 src/pages/Signup.vue create mode 100644 src/stores/authStore.js create mode 100644 tools/db/install-db-setup.sh create mode 100644 tools/db/photobooth-db.env.example create mode 100644 tools/db/photobooth-db.service create mode 100644 tools/db/photobooth-db.timer create mode 100644 tools/db/setup-db.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..807cd8a --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Backend API base URL (no trailing slash) +# Example: https://api.example.com +VITE_API_BASE_URL= diff --git a/.gitea/workflows/config.yaml b/.gitea/workflows/config.yaml index e69de29..4cb633c 100644 --- a/.gitea/workflows/config.yaml +++ b/.gitea/workflows/config.yaml @@ -0,0 +1,47 @@ +name: Deploy photobooth + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: linux_amd64 + + steps: + - name: Deploy to Debian Server + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.SSH_KEY }} + port: 22 + script: | + set -eo pipefail + + APP_PATH="/var/www/app/photobooth" + TEMP_PATH="/tmp/photobooth" + + mkdir -p $APP_PATH + + rm -rf $TEMP_PATH + + git clonehttps://git.sijaarc.xyz/HayemR/photobooth.git $TEMP_PATH + + rsync -avC --delete \ + --no-perms \ + --no-owner \ + --no-group \ + --no-times \ + $TEMP_PATH/ $APP_PATH/ + + cd $APP_PATH + + npm install + npm run build + + pm2 delete photobooth || true + pm2 serve dist 3000 --name photobooth --spa + + pm2 save diff --git a/README.md b/README.md index 4ddca76..dbbf515 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,14 @@ See [Vite Configuration Reference](https://vite.dev/config/). yarn ``` +### Environment + +Create `.env.local` (or copy from `.env.example`) and set: + +```sh +VITE_API_BASE_URL=https://your-api.example.com +``` + ### Compile and Hot-Reload for Development ```sh @@ -36,3 +44,22 @@ yarn dev ```sh yarn build ``` + +## Backend requirements (for auth + saved strips) + +This app expects a backend that implements: + +- `POST /auth/signup` +- `POST /auth/login` +- `POST /auth/refresh` (uses HttpOnly refresh cookie) +- `POST /auth/logout` +- `GET /me` +- `GET /media?type=strip` +- `POST /media` (accepts `dataUrl` for strip PNG) +- `DELETE /media/:id` + +If frontend and backend are on different domains, backend must support: + +- CORS with an explicit `Access-Control-Allow-Origin` (not `*`) +- `Access-Control-Allow-Credentials: true` +- refresh cookie set with `HttpOnly; Secure; SameSite=None` diff --git a/index.html b/index.html index 57e4395..b25584e 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,7 @@ - + Photobooth diff --git a/src/App.vue b/src/App.vue index bce4007..5906359 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,5 +1,20 @@ @@ -410,73 +375,6 @@ onMounted(() => { background: #d7e7f6; } -.aesthetic { - display: grid; - gap: 24px; -} - -.slider-controls { - display: flex; - gap: 10px; -} - -.circle-btn { - width: 38px; - height: 38px; - border-radius: 50%; - border: none; - background: rgba(124, 59, 84, 0.12); - color: #7c3b54; - font-size: 1.2rem; - cursor: pointer; -} - -.aesthetic-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 18px; -} - -.aesthetic-card { - padding: 16px; - border-radius: 24px; - background: rgba(255, 255, 255, 0.85); - box-shadow: 0 18px 40px rgba(200, 90, 124, 0.12); - display: grid; - gap: 12px; - text-align: center; -} - -.aesthetic-card h3 { - margin: 0; - font-size: 1.05rem; -} - -.aesthetic-card span { - color: var(--muted); - font-size: 0.85rem; - text-transform: uppercase; - letter-spacing: 0.12em; -} - -.card-image { - height: 180px; - border-radius: 18px; - background: linear-gradient(145deg, #f6d1dd, #f9f0f4); -} - -.aesthetic-card.noir .card-image { - background: linear-gradient(160deg, #2b2730, #6a646f); -} - -.aesthetic-card.retro .card-image { - background: linear-gradient(160deg, #f3b6c7, #d782b4); -} - -.aesthetic-card.glow .card-image { - background: linear-gradient(160deg, #f5c6a1, #f9e1ba); -} - .floating { position: fixed; right: 32px; diff --git a/src/pages/Login.vue b/src/pages/Login.vue new file mode 100644 index 0000000..a302ea2 --- /dev/null +++ b/src/pages/Login.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/src/pages/Result.vue b/src/pages/Result.vue index 931a7d6..aa1ff81 100644 --- a/src/pages/Result.vue +++ b/src/pages/Result.vue @@ -5,16 +5,23 @@ import { useRouter } from 'vue-router' import { useMotion } from '@vueuse/motion' import html2canvas from 'html2canvas' +import { apiRequest } from '../api' +import { useAuthStore } from '../stores/authStore' import { usePhotoboothStore } from '../stores/photoboothStore' import { DEFAULT_STRIP_THEME_ID, STRIP_THEME_MAP } from '../data/stripThemes' import PhotoStrip from '../components/PhotoStrip.vue' +const auth = useAuthStore() const store = usePhotoboothStore() const router = useRouter() const stripRef = ref(null) const wrapperRef = ref(null) const isExporting = ref(false) const stripDataUrl = ref('') + +const isSaving = ref(false) +const saveError = ref('') +const saved = ref(false) const stripSize = computed(() => { const theme = STRIP_THEME_MAP[store.stripTheme] || STRIP_THEME_MAP[DEFAULT_STRIP_THEME_ID] return theme.size @@ -92,6 +99,39 @@ async function handleDownload() { link.remove() } +async function handleSave() { + saveError.value = '' + + if (!auth.isAuthenticated) { + router.push({ name: 'login', query: { returnTo: '/result' } }) + return + } + + if (saved.value || isSaving.value) return + + isSaving.value = true + try { + const dataUrl = stripDataUrl.value || (await renderStrip()) + if (!dataUrl) return + + await apiRequest('/media', { + method: 'POST', + body: { + type: 'strip', + dataUrl, + themeId: store.stripTheme, + meta: { photoCount: 3 }, + }, + }) + + saved.value = true + } catch (err) { + saveError.value = err?.message || 'Failed to save.' + } finally { + isSaving.value = false + } +} + function startOver() { store.resetSession() router.push('/') @@ -115,12 +155,16 @@ function startOver() { + +

{{ saveError }}

@@ -169,6 +213,15 @@ function startOver() { gap: 12px; } +.save-error { + margin: 0; + color: #a13b56; + background: rgba(255, 236, 243, 0.7); + border: 1px solid rgba(200, 90, 124, 0.25); + padding: 10px 12px; + border-radius: 14px; +} + .primary { border: none; border-radius: 999px; diff --git a/src/pages/Signup.vue b/src/pages/Signup.vue new file mode 100644 index 0000000..41270b8 --- /dev/null +++ b/src/pages/Signup.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/src/plugins/router.js b/src/plugins/router.js index 69587ea..4885ce5 100644 --- a/src/plugins/router.js +++ b/src/plugins/router.js @@ -5,6 +5,11 @@ import Home from '../pages/Home.vue' import Capture from '../pages/Capture.vue' import Customize from '../pages/Customize.vue' import Result from '../pages/Result.vue' +import Login from '../pages/Login.vue' +import Signup from '../pages/Signup.vue' +import Account from '../pages/Account.vue' + +import { useAuthStore } from '../stores/authStore' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -23,16 +28,31 @@ const router = createRouter({ component: Result, meta: { requiresPhotos: true }, }, + { path: '/login', name: 'login', component: Login }, + { path: '/signup', name: 'signup', component: Signup }, + { path: '/account', name: 'account', component: Account, meta: { requiresAuth: true } }, ], }) -router.beforeEach((to) => { - if (!to.meta?.requiresPhotos) return true +router.beforeEach(async (to) => { + const auth = useAuthStore() - const store = usePhotoboothStore() - if (store.photos.length >= 3) return true + if (to.meta?.requiresAuth && !auth.isAuthenticated) { + // Try restore session via refresh-cookie before redirecting. + await auth.refresh() + if (!auth.isAuthenticated) { + return { name: 'login', query: { returnTo: to.fullPath } } + } + } - return { name: 'home' } + if (to.meta?.requiresPhotos) { + const store = usePhotoboothStore() + if (store.photos.length < 3) { + return { name: 'home' } + } + } + + return true }) export default router diff --git a/src/stores/authStore.js b/src/stores/authStore.js new file mode 100644 index 0000000..ba12782 --- /dev/null +++ b/src/stores/authStore.js @@ -0,0 +1,151 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' + +import { apiRequest, clearAccessToken, setAccessToken, setRefreshHandler } from '../api' + +export const useAuthStore = defineStore('auth', () => { + const user = ref(null) + const accessToken = ref('') + const status = ref('idle') + + const isAuthenticated = computed(() => Boolean(accessToken.value)) + + function applyAccessToken(token) { + accessToken.value = token || '' + setAccessToken(accessToken.value) + } + + function clearSession() { + user.value = null + accessToken.value = '' + clearAccessToken() + } + + async function refresh() { + status.value = 'loading' + + try { + const data = await apiRequest('/auth/refresh', { + method: 'POST', + skipAuth: true, + skipRefresh: true, + }) + + const token = data?.accessToken || '' + if (!token) { + clearSession() + status.value = 'idle' + return false + } + + applyAccessToken(token) + status.value = 'idle' + return true + } catch { + clearSession() + status.value = 'idle' + return false + } + } + + async function fetchMe() { + try { + const data = await apiRequest('/me', { method: 'GET' }) + user.value = data?.user || data || null + return user.value + } catch { + user.value = null + return null + } + } + + async function bootstrap() { + // Try to restore session using refresh cookie. + const ok = await refresh() + if (ok) { + await fetchMe() + } + } + + async function login({ email, password }) { + status.value = 'loading' + try { + const data = await apiRequest('/auth/login', { + method: 'POST', + skipAuth: true, + body: { email, password }, + }) + + applyAccessToken(data?.accessToken || '') + user.value = data?.user || null + + status.value = 'idle' + return true + } catch (err) { + clearSession() + status.value = 'error' + throw err + } + } + + async function signup({ email, password, name }) { + status.value = 'loading' + try { + const data = await apiRequest('/auth/signup', { + method: 'POST', + skipAuth: true, + body: { email, password, name }, + }) + + applyAccessToken(data?.accessToken || '') + user.value = data?.user || null + + status.value = 'idle' + return true + } catch (err) { + clearSession() + status.value = 'error' + throw err + } + } + + async function logout() { + status.value = 'loading' + try { + await apiRequest('/auth/logout', { method: 'POST', skipRefresh: true }) + } finally { + clearSession() + status.value = 'idle' + } + } + + // Allow apiRequest() to refresh tokens automatically on 401. + setRefreshHandler(async () => { + const data = await apiRequest('/auth/refresh', { + method: 'POST', + skipAuth: true, + skipRefresh: true, + }) + + const token = data?.accessToken || '' + applyAccessToken(token) + return token + }) + + // Keep api module token in sync if a token was already set. + setAccessToken(accessToken.value) + + return { + user, + accessToken, + status, + isAuthenticated, + bootstrap, + fetchMe, + refresh, + login, + signup, + logout, + clearSession, + } +}) diff --git a/tools/db/install-db-setup.sh b/tools/db/install-db-setup.sh new file mode 100644 index 0000000..85bff2e --- /dev/null +++ b/tools/db/install-db-setup.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "${EUID}" -ne 0 ]; then + echo "Run as root: sudo bash tools/db/install-db-setup.sh" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +MYSQL_ROOT_USER="${MYSQL_ROOT_USER:-root}" +MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-}" +MYSQL_HOST="${MYSQL_HOST:-127.0.0.1}" +MYSQL_PORT="${MYSQL_PORT:-3306}" +MYSQL_DATABASE="${MYSQL_DATABASE:-photobooth}" +MYSQL_APP_USER="${MYSQL_APP_USER:-photobooth_app}" +MYSQL_APP_PASSWORD="${MYSQL_APP_PASSWORD:-}" + +if [ -z "$MYSQL_ROOT_PASSWORD" ] || [ -z "$MYSQL_APP_PASSWORD" ]; then + echo "Set MYSQL_ROOT_PASSWORD and MYSQL_APP_PASSWORD before running." >&2 + exit 1 +fi + +install -m 0755 "$SCRIPT_DIR/setup-db.sh" /usr/local/bin/photobooth-db-setup +install -m 0644 "$SCRIPT_DIR/photobooth-db.service" /etc/systemd/system/photobooth-db.service +install -m 0644 "$SCRIPT_DIR/photobooth-db.timer" /etc/systemd/system/photobooth-db.timer + +cat > /etc/photobooth-db.env <&2 + exit 1 +fi + +if [ -z "$MYSQL_APP_PASSWORD" ]; then + echo "MYSQL_APP_PASSWORD is required." >&2 + exit 1 +fi + +if [ -f "$MARKER_FILE" ]; then + echo "Database already initialized: $MARKER_FILE" + exit 0 +fi + +mkdir -p "$(dirname "$MARKER_FILE")" + +MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql \ + --protocol=TCP \ + -h "$MYSQL_HOST" \ + -P "$MYSQL_PORT" \ + -u "$MYSQL_ROOT_USER" <