feat: implement Iteration 0 foundation (backend + Flutter client)
Backend (Go): - Project structure with chi router, pgxpool, goose migrations - JWT auth (access/refresh tokens) with Firebase token verification - NoopTokenVerifier for local dev without Firebase credentials - PostgreSQL user repository with atomic profile updates (transactions) - Mifflin-St Jeor calorie calculation based on profile data - REST API: POST /auth/login, /auth/refresh, /auth/logout, GET/PUT /profile, GET /health - Middleware: auth, CORS (localhost wildcard), logging, recovery, request_id - Unit tests (51 passing) and integration tests (testcontainers) - Docker Compose setup with postgres healthcheck and graceful shutdown Flutter client: - Riverpod state management with GoRouter navigation - Firebase Auth (email/password + Google sign-in with web popup support) - Platform-aware API URLs (web/Android/iOS) - Dio HTTP client with JWT auth interceptor and concurrent refresh handling - Secure token storage - Screens: Login, Register, Home (tabs: Menu, Recipes, Products, Profile) - Unit tests (17 passing) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
18
.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
# Firebase credentials
|
||||
backend/firebase-credentials.json
|
||||
client/android/app/google-services.json
|
||||
client/ios/Runner/GoogleService-Info.plist
|
||||
|
||||
# Backend
|
||||
backend/.env
|
||||
|
||||
# Client build artifacts
|
||||
client/build/
|
||||
client/.dart_tool/
|
||||
client/.flutter-plugins
|
||||
client/.flutter-plugins-dependencies
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
14
backend/.env.example
Normal file
@@ -0,0 +1,14 @@
|
||||
# Database
|
||||
DATABASE_URL=postgres://food_ai:food_ai_local@localhost:5432/food_ai?sslmode=disable
|
||||
|
||||
# Firebase
|
||||
FIREBASE_CREDENTIALS_FILE=./firebase-credentials.json
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=change-me-in-production
|
||||
JWT_ACCESS_DURATION=15m
|
||||
JWT_REFRESH_DURATION=720h
|
||||
|
||||
# Server
|
||||
PORT=8080
|
||||
ALLOWED_ORIGINS=http://localhost:3000
|
||||
16
backend/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
# Build
|
||||
FROM golang:1.25-alpine AS builder
|
||||
WORKDIR /build
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -o /app/server ./cmd/server
|
||||
|
||||
# Run
|
||||
FROM alpine:3.19
|
||||
RUN apk add --no-cache ca-certificates
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/server .
|
||||
COPY migrations ./migrations
|
||||
EXPOSE 8080
|
||||
CMD ["./server"]
|
||||
45
backend/Makefile
Normal file
@@ -0,0 +1,45 @@
|
||||
.PHONY: run test lint migrate-up migrate-down migrate-create migrate-status docker-up docker-down docker-logs
|
||||
|
||||
ifneq (,$(wildcard .env))
|
||||
include .env
|
||||
export
|
||||
endif
|
||||
|
||||
# Run for development
|
||||
run:
|
||||
go run ./cmd/server
|
||||
|
||||
# Tests
|
||||
test:
|
||||
go test ./... -v -race -count=1
|
||||
|
||||
# Integration tests (require Docker)
|
||||
test-integration:
|
||||
go test -tags=integration ./... -v -race -count=1
|
||||
|
||||
# Linter
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
# Migrations
|
||||
migrate-up:
|
||||
goose -dir migrations postgres "$(DATABASE_URL)" up
|
||||
|
||||
migrate-down:
|
||||
goose -dir migrations postgres "$(DATABASE_URL)" down
|
||||
|
||||
migrate-create:
|
||||
goose -dir migrations create $(name) sql
|
||||
|
||||
migrate-status:
|
||||
goose -dir migrations postgres "$(DATABASE_URL)" status
|
||||
|
||||
# Docker
|
||||
docker-up:
|
||||
docker compose up -d
|
||||
|
||||
docker-down:
|
||||
docker compose down
|
||||
|
||||
docker-logs:
|
||||
docker compose logs -f app
|
||||
137
backend/README.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# FoodAI Backend
|
||||
|
||||
Go REST API с авторизацией через Firebase, JWT и PostgreSQL.
|
||||
|
||||
## Стек
|
||||
|
||||
- **Go 1.23** — язык
|
||||
- **chi** — HTTP-роутер
|
||||
- **pgx / pgxpool** — PostgreSQL-драйвер
|
||||
- **goose** — миграции
|
||||
- **golang-jwt/v5** — JWT
|
||||
- **Firebase Admin SDK** — верификация токенов
|
||||
|
||||
## Требования
|
||||
|
||||
- Go 1.23+
|
||||
- Docker & Docker Compose
|
||||
- [goose](https://github.com/pressly/goose) (`go install github.com/pressly/goose/v3/cmd/goose@latest`)
|
||||
- Файл `firebase-credentials.json` (сервисный аккаунт Firebase)
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### 1. Переменные окружения
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Отредактируйте `.env`:
|
||||
|
||||
| Переменная | Описание | По умолчанию |
|
||||
|---|---|---|
|
||||
| `DATABASE_URL` | DSN подключения к PostgreSQL | `postgres://food_ai:food_ai_local@localhost:5432/food_ai?sslmode=disable` |
|
||||
| `FIREBASE_CREDENTIALS_FILE` | Путь к JSON-ключу сервисного аккаунта Firebase | `./firebase-credentials.json` |
|
||||
| `JWT_SECRET` | Секрет для подписи JWT | — |
|
||||
| `JWT_ACCESS_DURATION` | Время жизни access-токена | `15m` |
|
||||
| `JWT_REFRESH_DURATION` | Время жизни refresh-токена | `720h` |
|
||||
| `PORT` | Порт сервера | `8080` |
|
||||
| `ALLOWED_ORIGINS` | CORS-разрешённые источники | `http://localhost:3000` |
|
||||
|
||||
### 2. Запуск через Docker Compose
|
||||
|
||||
Поднимает PostgreSQL и собирает приложение:
|
||||
|
||||
```bash
|
||||
make docker-up
|
||||
```
|
||||
|
||||
### 3. Запуск локально (только БД в Docker)
|
||||
|
||||
```bash
|
||||
# Запустить только PostgreSQL
|
||||
docker compose up -d postgres
|
||||
|
||||
# Применить миграции
|
||||
make migrate-up
|
||||
|
||||
# Запустить сервер
|
||||
make run
|
||||
```
|
||||
|
||||
## Команды
|
||||
|
||||
| Команда | Описание |
|
||||
|---|---|
|
||||
| `make run` | Запустить сервер в режиме разработки |
|
||||
| `make test` | Запустить unit-тесты |
|
||||
| `make test-integration` | Запустить интеграционные тесты (требует Docker) |
|
||||
| `make lint` | Проверить код через golangci-lint |
|
||||
| `make docker-up` | Поднять PostgreSQL + приложение |
|
||||
| `make docker-down` | Остановить контейнеры |
|
||||
| `make docker-logs` | Логи приложения |
|
||||
| `make migrate-up` | Применить миграции |
|
||||
| `make migrate-down` | Откатить последнюю миграцию |
|
||||
| `make migrate-status` | Статус миграций |
|
||||
| `make migrate-create name=<name>` | Создать новую миграцию |
|
||||
|
||||
## API
|
||||
|
||||
### Публичные эндпоинты
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|---|---|---|
|
||||
| `GET` | `/health` | Проверка состояния сервера и БД |
|
||||
| `POST` | `/auth/login` | Вход через Firebase ID-токен |
|
||||
| `POST` | `/auth/refresh` | Обновление JWT по refresh-токену |
|
||||
| `POST` | `/auth/logout` | Выход (инвалидация refresh-токена) |
|
||||
|
||||
### Защищённые эндпоинты (Bearer JWT)
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|---|---|---|
|
||||
| `GET` | `/profile` | Получить профиль пользователя |
|
||||
| `PUT` | `/profile` | Обновить профиль пользователя |
|
||||
|
||||
### Примеры запросов
|
||||
|
||||
**Логин:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"firebase_token": "<FIREBASE_ID_TOKEN>"}'
|
||||
```
|
||||
|
||||
**Получить профиль:**
|
||||
```bash
|
||||
curl http://localhost:8080/profile \
|
||||
-H "Authorization: Bearer <ACCESS_TOKEN>"
|
||||
```
|
||||
|
||||
**Обновить профиль:**
|
||||
```bash
|
||||
curl -X PUT http://localhost:8080/profile \
|
||||
-H "Authorization: Bearer <ACCESS_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"height_cm": 180, "weight_kg": 75.5, "age": 28, "gender": "male", "activity": "moderate", "goal": "maintain"}'
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
backend/
|
||||
├── cmd/server/ # Точка входа
|
||||
├── internal/
|
||||
│ ├── auth/ # Firebase-верификация, JWT, сервис и хэндлер авторизации
|
||||
│ ├── config/ # Конфигурация через переменные окружения
|
||||
│ ├── database/ # Подключение к PostgreSQL (pgxpool)
|
||||
│ ├── middleware/ # RequestID, Logging, Recovery, CORS, Auth
|
||||
│ ├── server/ # Роутер (chi)
|
||||
│ ├── testutil/ # Вспомогательные утилиты для тестов
|
||||
│ └── user/ # Модель, репозиторий, сервис, хэндлер, расчёт калорий
|
||||
├── migrations/ # SQL-миграции (goose)
|
||||
├── .env.example
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
└── Makefile
|
||||
```
|
||||
111
backend/cmd/server/main.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/food-ai/backend/internal/auth"
|
||||
"github.com/food-ai/backend/internal/config"
|
||||
"github.com/food-ai/backend/internal/database"
|
||||
"github.com/food-ai/backend/internal/middleware"
|
||||
"github.com/food-ai/backend/internal/server"
|
||||
"github.com/food-ai/backend/internal/user"
|
||||
)
|
||||
|
||||
// jwtAdapter adapts auth.JWTManager to middleware.AccessTokenValidator.
|
||||
type jwtAdapter struct {
|
||||
jm *auth.JWTManager
|
||||
}
|
||||
|
||||
func (a *jwtAdapter) ValidateAccessToken(tokenStr string) (*middleware.TokenClaims, error) {
|
||||
claims, err := a.jm.ValidateAccessToken(tokenStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &middleware.TokenClaims{
|
||||
UserID: claims.UserID,
|
||||
Plan: claims.Plan,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
if err := run(); err != nil {
|
||||
slog.Error("fatal error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
pool, err := database.NewPool(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to database: %w", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
slog.Info("connected to database")
|
||||
|
||||
// Firebase auth
|
||||
firebaseAuth, err := auth.NewFirebaseAuthOrNoop(cfg.FirebaseCredentialsFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init firebase auth: %w", err)
|
||||
}
|
||||
|
||||
// JWT manager
|
||||
jwtManager := auth.NewJWTManager(cfg.JWTSecret, cfg.JWTAccessDuration, cfg.JWTRefreshDuration)
|
||||
|
||||
// User domain
|
||||
userRepo := user.NewRepository(pool)
|
||||
userService := user.NewService(userRepo)
|
||||
userHandler := user.NewHandler(userService)
|
||||
|
||||
// Auth domain
|
||||
authService := auth.NewService(firebaseAuth, userRepo, jwtManager)
|
||||
authHandler := auth.NewHandler(authService)
|
||||
|
||||
// Auth middleware
|
||||
authMW := middleware.Auth(&jwtAdapter{jm: jwtManager})
|
||||
|
||||
// Router
|
||||
router := server.NewRouter(pool, authHandler, userHandler, authMW, cfg.AllowedOrigins)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||
Handler: router,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
slog.Info("server starting", "port", cfg.Port)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error("server error", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
slog.Info("shutting down...")
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
return srv.Shutdown(shutdownCtx)
|
||||
}
|
||||
36
backend/docker-compose.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: food_ai
|
||||
POSTGRES_USER: food_ai
|
||||
POSTGRES_PASSWORD: food_ai_local
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U food_ai -d food_ai"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "9090:8080"
|
||||
environment:
|
||||
DATABASE_URL: postgres://food_ai:food_ai_local@postgres:5432/food_ai?sslmode=disable
|
||||
FIREBASE_CREDENTIALS_FILE: /app/firebase-credentials.json
|
||||
JWT_SECRET: local-dev-secret-change-in-prod
|
||||
ALLOWED_ORIGINS: http://localhost:3000,http://localhost:9090
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./firebase-credentials.json:/app/firebase-credentials.json:ro
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
118
backend/go.mod
Normal file
@@ -0,0 +1,118 @@
|
||||
module github.com/food-ai/backend
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
firebase.google.com/go/v4 v4.19.0
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
github.com/kelseyhightower/envconfig v1.4.0
|
||||
github.com/pressly/goose/v3 v3.26.0
|
||||
github.com/testcontainers/testcontainers-go v0.40.0
|
||||
google.golang.org/api v0.266.0
|
||||
)
|
||||
|
||||
require (
|
||||
cel.dev/expr v0.24.0 // indirect
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.18.1 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/firestore v1.21.0 // indirect
|
||||
cloud.google.com/go/iam v1.5.3 // indirect
|
||||
cloud.google.com/go/longrunning v0.8.0 // indirect
|
||||
cloud.google.com/go/monitoring v1.24.3 // indirect
|
||||
cloud.google.com/go/storage v1.56.0 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
|
||||
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/docker v28.5.1+incompatible // indirect
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/ebitengine/purego v0.8.4 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.35.0 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.8 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.1.0 // indirect
|
||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.0 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/appengine/v2 v2.0.6 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||
google.golang.org/grpc v1.78.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
322
backend/go.sum
Normal file
@@ -0,0 +1,322 @@
|
||||
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
|
||||
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs=
|
||||
cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/firestore v1.21.0 h1:BhopUsx7kh6NFx77ccRsHhrtkbJUmDAxNY3uapWdjcM=
|
||||
cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4=
|
||||
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
|
||||
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
|
||||
cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY=
|
||||
cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw=
|
||||
cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8=
|
||||
cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk=
|
||||
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
|
||||
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
|
||||
cloud.google.com/go/storage v1.56.0 h1:iixmq2Fse2tqxMbWhLWC9HfBj1qdxqAmiK8/eqtsLxI=
|
||||
cloud.google.com/go/storage v1.56.0/go.mod h1:Tpuj6t4NweCLzlNbw9Z9iwxEkrSem20AetIeH/shgVU=
|
||||
cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
|
||||
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
firebase.google.com/go/v4 v4.19.0 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8=
|
||||
firebase.google.com/go/v4 v4.19.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0 h1:4LP6hvB4I5ouTbGgWtixJhgED6xdf67twf9PoY96Tbg=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0/go.mod h1:jUZ5LYlw40WMd07qxcQJD5M40aUxrfwqQX1g7zxYnrQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo=
|
||||
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
|
||||
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0=
|
||||
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
||||
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM=
|
||||
github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
||||
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM=
|
||||
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
|
||||
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.8 h1:NpbJl/eVbvrGE0MJ6X16X9SAifesl6Fwxg/YmCvubRI=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.8/go.mod h1:mi7YA+gCzVem12exXy46ZespvGtX/lZmD/RLnQhVW7U=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
|
||||
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
|
||||
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
|
||||
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
|
||||
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
|
||||
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
|
||||
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.38.0 h1:ZoYbqX7OaA/TAikspPl3ozPI6iY6LiIY9I8cUfm+pJs=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.266.0 h1:hco+oNCf9y7DmLeAtHJi/uBAY7n/7XC9mZPxu1ROiyk=
|
||||
google.golang.org/api v0.266.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0=
|
||||
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
|
||||
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
|
||||
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM=
|
||||
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||
89
backend/internal/auth/firebase.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
firebase "firebase.google.com/go/v4"
|
||||
firebaseAuth "firebase.google.com/go/v4/auth"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
// TokenVerifier abstracts Firebase token verification for testability.
|
||||
type TokenVerifier interface {
|
||||
VerifyToken(ctx context.Context, idToken string) (uid, email, name, avatarURL string, err error)
|
||||
}
|
||||
|
||||
type FirebaseAuth struct {
|
||||
client *firebaseAuth.Client
|
||||
}
|
||||
|
||||
// noopTokenVerifier is used in local development when Firebase credentials are not available.
|
||||
// It rejects all tokens with a clear error message.
|
||||
type noopTokenVerifier struct{}
|
||||
|
||||
func (n *noopTokenVerifier) VerifyToken(_ context.Context, _ string) (uid, email, name, avatarURL string, err error) {
|
||||
return "", "", "", "", fmt.Errorf("Firebase auth is not configured (running in noop mode)")
|
||||
}
|
||||
|
||||
// NewFirebaseAuthOrNoop tries to initialize Firebase auth from the credentials file.
|
||||
// If the file is missing, empty, or not a valid service account, it logs a warning
|
||||
// and returns a noop verifier that rejects all tokens.
|
||||
func NewFirebaseAuthOrNoop(credentialsFile string) (TokenVerifier, error) {
|
||||
data, err := os.ReadFile(credentialsFile)
|
||||
if err != nil || len(data) == 0 {
|
||||
slog.Warn("firebase credentials not found, running without Firebase auth", "file", credentialsFile)
|
||||
return &noopTokenVerifier{}, nil
|
||||
}
|
||||
|
||||
var creds map[string]interface{}
|
||||
if err := json.Unmarshal(data, &creds); err != nil || creds["type"] == nil {
|
||||
slog.Warn("firebase credentials invalid, running without Firebase auth", "file", credentialsFile)
|
||||
return &noopTokenVerifier{}, nil
|
||||
}
|
||||
|
||||
fa, err := NewFirebaseAuth(credentialsFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fa, nil
|
||||
}
|
||||
|
||||
func NewFirebaseAuth(credentialsFile string) (*FirebaseAuth, error) {
|
||||
opt := option.WithCredentialsFile(credentialsFile)
|
||||
app, err := firebase.NewApp(context.Background(), nil, opt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client, err := app.Auth(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &FirebaseAuth{client: client}, nil
|
||||
}
|
||||
|
||||
func (f *FirebaseAuth) VerifyToken(ctx context.Context, idToken string) (uid, email, name, avatarURL string, err error) {
|
||||
token, err := f.client.VerifyIDToken(ctx, idToken)
|
||||
if err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
|
||||
uid = token.UID
|
||||
|
||||
if v, ok := token.Claims["email"].(string); ok {
|
||||
email = v
|
||||
}
|
||||
if v, ok := token.Claims["name"].(string); ok {
|
||||
name = v
|
||||
}
|
||||
if v, ok := token.Claims["picture"].(string); ok {
|
||||
avatarURL = v
|
||||
}
|
||||
|
||||
return uid, email, name, avatarURL, nil
|
||||
}
|
||||
106
backend/internal/auth/handler.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/food-ai/backend/internal/middleware"
|
||||
)
|
||||
|
||||
const maxRequestBodySize = 1 << 20 // 1 MB
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
FirebaseToken string `json:"firebase_token"`
|
||||
}
|
||||
|
||||
type refreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
|
||||
var req loginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.FirebaseToken == "" {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "firebase_token is required")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.service.Login(r.Context(), req.FirebaseToken)
|
||||
if err != nil {
|
||||
writeErrorJSON(w, http.StatusUnauthorized, "authentication failed")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
|
||||
var req refreshRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.RefreshToken == "" {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "refresh_token is required")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.service.Refresh(r.Context(), req.RefreshToken)
|
||||
if err != nil {
|
||||
writeErrorJSON(w, http.StatusUnauthorized, "invalid refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.Logout(r.Context(), userID); err != nil {
|
||||
writeErrorJSON(w, http.StatusInternalServerError, "logout failed")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func writeErrorJSON(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil {
|
||||
slog.Error("failed to write error response", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
slog.Error("failed to write JSON response", "err", err)
|
||||
}
|
||||
}
|
||||
272
backend/internal/auth/handler_integration_test.go
Normal file
@@ -0,0 +1,272 @@
|
||||
//go:build integration
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/food-ai/backend/internal/auth/mocks"
|
||||
"github.com/food-ai/backend/internal/middleware"
|
||||
"github.com/food-ai/backend/internal/testutil"
|
||||
"github.com/food-ai/backend/internal/user"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// testValidator adapts JWTManager to middleware.AccessTokenValidator for tests.
|
||||
type testValidator struct {
|
||||
jm *JWTManager
|
||||
}
|
||||
|
||||
func (v *testValidator) ValidateAccessToken(tokenStr string) (*middleware.TokenClaims, error) {
|
||||
claims, err := v.jm.ValidateAccessToken(tokenStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &middleware.TokenClaims{UserID: claims.UserID, Plan: claims.Plan}, nil
|
||||
}
|
||||
|
||||
func setupIntegrationTest(t *testing.T) (*chi.Mux, *JWTManager) {
|
||||
t.Helper()
|
||||
pool := testutil.SetupTestDB(t)
|
||||
|
||||
verifier := &mocks.MockTokenVerifier{
|
||||
VerifyTokenFn: func(ctx context.Context, idToken string) (string, string, string, string, error) {
|
||||
return "fb-" + idToken, idToken + "@test.com", "Test User", "", nil
|
||||
},
|
||||
}
|
||||
|
||||
jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour)
|
||||
repo := user.NewRepository(pool)
|
||||
svc := NewService(verifier, repo, jm)
|
||||
handler := NewHandler(svc)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/auth/login", handler.Login)
|
||||
r.Post("/auth/refresh", handler.Refresh)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.Auth(&testValidator{jm: jm}))
|
||||
r.Post("/auth/logout", handler.Logout)
|
||||
})
|
||||
|
||||
return r, jm
|
||||
}
|
||||
|
||||
func TestIntegration_Login(t *testing.T) {
|
||||
router, _ := setupIntegrationTest(t)
|
||||
|
||||
body := `{"firebase_token":"user1"}`
|
||||
req := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp LoginResponse
|
||||
json.NewDecoder(rr.Body).Decode(&resp)
|
||||
if resp.AccessToken == "" {
|
||||
t.Error("expected non-empty access token")
|
||||
}
|
||||
if resp.RefreshToken == "" {
|
||||
t.Error("expected non-empty refresh token")
|
||||
}
|
||||
if resp.User == nil {
|
||||
t.Fatal("expected user in response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Login_EmptyToken(t *testing.T) {
|
||||
router, _ := setupIntegrationTest(t)
|
||||
|
||||
body := `{"firebase_token":""}`
|
||||
req := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Login_InvalidBody(t *testing.T) {
|
||||
router, _ := setupIntegrationTest(t)
|
||||
|
||||
req := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString("invalid"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Refresh(t *testing.T) {
|
||||
router, _ := setupIntegrationTest(t)
|
||||
|
||||
// First login
|
||||
loginBody := `{"firebase_token":"user2"}`
|
||||
loginReq := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(loginBody))
|
||||
loginReq.Header.Set("Content-Type", "application/json")
|
||||
loginRR := httptest.NewRecorder()
|
||||
router.ServeHTTP(loginRR, loginReq)
|
||||
|
||||
var loginResp LoginResponse
|
||||
json.NewDecoder(loginRR.Body).Decode(&loginResp)
|
||||
|
||||
// Then refresh
|
||||
refreshBody, _ := json.Marshal(refreshRequest{RefreshToken: loginResp.RefreshToken})
|
||||
refreshReq := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBuffer(refreshBody))
|
||||
refreshReq.Header.Set("Content-Type", "application/json")
|
||||
refreshRR := httptest.NewRecorder()
|
||||
router.ServeHTTP(refreshRR, refreshReq)
|
||||
|
||||
if refreshRR.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", refreshRR.Code, refreshRR.Body.String())
|
||||
}
|
||||
|
||||
var resp RefreshResponse
|
||||
json.NewDecoder(refreshRR.Body).Decode(&resp)
|
||||
if resp.AccessToken == "" {
|
||||
t.Error("expected non-empty access token")
|
||||
}
|
||||
if resp.RefreshToken == loginResp.RefreshToken {
|
||||
t.Error("expected rotated refresh token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Refresh_InvalidToken(t *testing.T) {
|
||||
router, _ := setupIntegrationTest(t)
|
||||
|
||||
body := `{"refresh_token":"nonexistent"}`
|
||||
req := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Refresh_EmptyToken(t *testing.T) {
|
||||
router, _ := setupIntegrationTest(t)
|
||||
|
||||
body := `{"refresh_token":""}`
|
||||
req := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Logout(t *testing.T) {
|
||||
router, _ := setupIntegrationTest(t)
|
||||
|
||||
// Login first
|
||||
loginBody := `{"firebase_token":"user3"}`
|
||||
loginReq := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(loginBody))
|
||||
loginReq.Header.Set("Content-Type", "application/json")
|
||||
loginRR := httptest.NewRecorder()
|
||||
router.ServeHTTP(loginRR, loginReq)
|
||||
|
||||
var loginResp LoginResponse
|
||||
json.NewDecoder(loginRR.Body).Decode(&loginResp)
|
||||
|
||||
// Logout
|
||||
logoutReq := httptest.NewRequest("POST", "/auth/logout", nil)
|
||||
logoutReq.Header.Set("Authorization", "Bearer "+loginResp.AccessToken)
|
||||
logoutRR := httptest.NewRecorder()
|
||||
router.ServeHTTP(logoutRR, logoutReq)
|
||||
|
||||
if logoutRR.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", logoutRR.Code, logoutRR.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Logout_NoAuth(t *testing.T) {
|
||||
router, _ := setupIntegrationTest(t)
|
||||
|
||||
req := httptest.NewRequest("POST", "/auth/logout", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_RefreshAfterLogout(t *testing.T) {
|
||||
router, _ := setupIntegrationTest(t)
|
||||
|
||||
// Login
|
||||
loginBody := `{"firebase_token":"user4"}`
|
||||
loginReq := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(loginBody))
|
||||
loginReq.Header.Set("Content-Type", "application/json")
|
||||
loginRR := httptest.NewRecorder()
|
||||
router.ServeHTTP(loginRR, loginReq)
|
||||
|
||||
var loginResp LoginResponse
|
||||
json.NewDecoder(loginRR.Body).Decode(&loginResp)
|
||||
|
||||
// Logout
|
||||
logoutReq := httptest.NewRequest("POST", "/auth/logout", nil)
|
||||
logoutReq.Header.Set("Authorization", "Bearer "+loginResp.AccessToken)
|
||||
logoutRR := httptest.NewRecorder()
|
||||
router.ServeHTTP(logoutRR, logoutReq)
|
||||
|
||||
// Try to refresh with old token
|
||||
refreshBody, _ := json.Marshal(refreshRequest{RefreshToken: loginResp.RefreshToken})
|
||||
refreshReq := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBuffer(refreshBody))
|
||||
refreshReq.Header.Set("Content-Type", "application/json")
|
||||
refreshRR := httptest.NewRecorder()
|
||||
router.ServeHTTP(refreshRR, refreshReq)
|
||||
|
||||
if refreshRR.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 after logout, got %d", refreshRR.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_OldRefreshTokenInvalid(t *testing.T) {
|
||||
router, _ := setupIntegrationTest(t)
|
||||
|
||||
// Login
|
||||
loginBody := `{"firebase_token":"user5"}`
|
||||
loginReq := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(loginBody))
|
||||
loginReq.Header.Set("Content-Type", "application/json")
|
||||
loginRR := httptest.NewRecorder()
|
||||
router.ServeHTTP(loginRR, loginReq)
|
||||
|
||||
var loginResp LoginResponse
|
||||
json.NewDecoder(loginRR.Body).Decode(&loginResp)
|
||||
oldRefreshToken := loginResp.RefreshToken
|
||||
|
||||
// Refresh (rotates token)
|
||||
refreshBody, _ := json.Marshal(refreshRequest{RefreshToken: oldRefreshToken})
|
||||
refreshReq := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBuffer(refreshBody))
|
||||
refreshReq.Header.Set("Content-Type", "application/json")
|
||||
refreshRR := httptest.NewRecorder()
|
||||
router.ServeHTTP(refreshRR, refreshReq)
|
||||
|
||||
// Try old refresh token again
|
||||
oldRefreshReq := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBuffer(refreshBody))
|
||||
oldRefreshReq.Header.Set("Content-Type", "application/json")
|
||||
oldRefreshRR := httptest.NewRecorder()
|
||||
router.ServeHTTP(oldRefreshRR, oldRefreshReq)
|
||||
|
||||
if oldRefreshRR.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 for old refresh token, got %d", oldRefreshRR.Code)
|
||||
}
|
||||
}
|
||||
69
backend/internal/auth/jwt.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type JWTManager struct {
|
||||
secret []byte
|
||||
accessDuration time.Duration
|
||||
refreshDuration time.Duration
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Plan string `json:"plan"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func NewJWTManager(secret string, accessDuration, refreshDuration time.Duration) *JWTManager {
|
||||
return &JWTManager{
|
||||
secret: []byte(secret),
|
||||
accessDuration: accessDuration,
|
||||
refreshDuration: refreshDuration,
|
||||
}
|
||||
}
|
||||
|
||||
func (j *JWTManager) GenerateAccessToken(userID, plan string) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Plan: plan,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(j.accessDuration)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(j.secret)
|
||||
}
|
||||
|
||||
func (j *JWTManager) GenerateRefreshToken() (string, time.Time) {
|
||||
token := uuid.NewString()
|
||||
expiresAt := time.Now().Add(j.refreshDuration)
|
||||
return token, expiresAt
|
||||
}
|
||||
|
||||
func (j *JWTManager) ValidateAccessToken(tokenStr string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return j.secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (j *JWTManager) AccessDuration() time.Duration {
|
||||
return j.accessDuration
|
||||
}
|
||||
86
backend/internal/auth/jwt_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGenerateAccessToken(t *testing.T) {
|
||||
jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour)
|
||||
|
||||
token, err := jm.GenerateAccessToken("user-123", "free")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if token == "" {
|
||||
t.Fatal("expected non-empty token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAccessToken_Valid(t *testing.T) {
|
||||
jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour)
|
||||
|
||||
token, _ := jm.GenerateAccessToken("user-123", "free")
|
||||
claims, err := jm.ValidateAccessToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if claims.UserID != "user-123" {
|
||||
t.Errorf("expected user_id 'user-123', got %q", claims.UserID)
|
||||
}
|
||||
if claims.Plan != "free" {
|
||||
t.Errorf("expected plan 'free', got %q", claims.Plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAccessToken_Expired(t *testing.T) {
|
||||
jm := NewJWTManager("test-secret", -1*time.Second, 720*time.Hour)
|
||||
|
||||
token, _ := jm.GenerateAccessToken("user-123", "free")
|
||||
_, err := jm.ValidateAccessToken(token)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for expired token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAccessToken_WrongSecret(t *testing.T) {
|
||||
jm1 := NewJWTManager("secret-1", 15*time.Minute, 720*time.Hour)
|
||||
jm2 := NewJWTManager("secret-2", 15*time.Minute, 720*time.Hour)
|
||||
|
||||
token, _ := jm1.GenerateAccessToken("user-123", "free")
|
||||
_, err := jm2.ValidateAccessToken(token)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAccessToken_InvalidToken(t *testing.T) {
|
||||
jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour)
|
||||
|
||||
_, err := jm.ValidateAccessToken("invalid-token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRefreshToken(t *testing.T) {
|
||||
jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour)
|
||||
|
||||
token, expiresAt := jm.GenerateRefreshToken()
|
||||
if token == "" {
|
||||
t.Fatal("expected non-empty refresh token")
|
||||
}
|
||||
if expiresAt.Before(time.Now()) {
|
||||
t.Fatal("expected future expiration time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRefreshToken_Unique(t *testing.T) {
|
||||
jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour)
|
||||
|
||||
token1, _ := jm.GenerateRefreshToken()
|
||||
token2, _ := jm.GenerateRefreshToken()
|
||||
if token1 == token2 {
|
||||
t.Fatal("expected unique refresh tokens")
|
||||
}
|
||||
}
|
||||
11
backend/internal/auth/mocks/token_verifier.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package mocks
|
||||
|
||||
import "context"
|
||||
|
||||
type MockTokenVerifier struct {
|
||||
VerifyTokenFn func(ctx context.Context, idToken string) (uid, email, name, avatarURL string, err error)
|
||||
}
|
||||
|
||||
func (m *MockTokenVerifier) VerifyToken(ctx context.Context, idToken string) (uid, email, name, avatarURL string, err error) {
|
||||
return m.VerifyTokenFn(ctx, idToken)
|
||||
}
|
||||
91
backend/internal/auth/service.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/food-ai/backend/internal/user"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
tokenVerifier TokenVerifier
|
||||
userRepo user.UserRepository
|
||||
jwtManager *JWTManager
|
||||
}
|
||||
|
||||
func NewService(tokenVerifier TokenVerifier, userRepo user.UserRepository, jwtManager *JWTManager) *Service {
|
||||
return &Service{
|
||||
tokenVerifier: tokenVerifier,
|
||||
userRepo: userRepo,
|
||||
jwtManager: jwtManager,
|
||||
}
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
User *user.User `json:"user"`
|
||||
}
|
||||
|
||||
type RefreshResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, firebaseToken string) (*LoginResponse, error) {
|
||||
uid, email, name, avatarURL, err := s.tokenVerifier.VerifyToken(ctx, firebaseToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("verify firebase token: %w", err)
|
||||
}
|
||||
|
||||
u, err := s.userRepo.UpsertByFirebaseUID(ctx, uid, email, name, avatarURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upsert user: %w", err)
|
||||
}
|
||||
|
||||
accessToken, err := s.jwtManager.GenerateAccessToken(u.ID, u.Plan)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate access token: %w", err)
|
||||
}
|
||||
|
||||
refreshToken, expiresAt := s.jwtManager.GenerateRefreshToken()
|
||||
if err := s.userRepo.SetRefreshToken(ctx, u.ID, refreshToken, expiresAt); err != nil {
|
||||
return nil, fmt.Errorf("set refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &LoginResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: int(s.jwtManager.AccessDuration().Seconds()),
|
||||
User: u,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Refresh(ctx context.Context, refreshToken string) (*RefreshResponse, error) {
|
||||
u, err := s.userRepo.FindByRefreshToken(ctx, refreshToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid refresh token: %w", err)
|
||||
}
|
||||
|
||||
accessToken, err := s.jwtManager.GenerateAccessToken(u.ID, u.Plan)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate access token: %w", err)
|
||||
}
|
||||
|
||||
newRefreshToken, expiresAt := s.jwtManager.GenerateRefreshToken()
|
||||
if err := s.userRepo.SetRefreshToken(ctx, u.ID, newRefreshToken, expiresAt); err != nil {
|
||||
return nil, fmt.Errorf("set refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &RefreshResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: newRefreshToken,
|
||||
ExpiresIn: int(s.jwtManager.AccessDuration().Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Logout(ctx context.Context, userID string) error {
|
||||
return s.userRepo.ClearRefreshToken(ctx, userID)
|
||||
}
|
||||
213
backend/internal/auth/service_test.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/food-ai/backend/internal/auth/mocks"
|
||||
"github.com/food-ai/backend/internal/user"
|
||||
umocks "github.com/food-ai/backend/internal/user/mocks"
|
||||
)
|
||||
|
||||
func newTestService(verifier *mocks.MockTokenVerifier, repo *umocks.MockUserRepository) *Service {
|
||||
jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour)
|
||||
return NewService(verifier, repo, jm)
|
||||
}
|
||||
|
||||
func TestLogin_Success(t *testing.T) {
|
||||
verifier := &mocks.MockTokenVerifier{
|
||||
VerifyTokenFn: func(ctx context.Context, idToken string) (string, string, string, string, error) {
|
||||
return "fb-uid", "test@example.com", "Test User", "https://avatar.url", nil
|
||||
},
|
||||
}
|
||||
repo := &umocks.MockUserRepository{
|
||||
UpsertByFirebaseUIDFn: func(ctx context.Context, uid, email, name, avatarURL string) (*user.User, error) {
|
||||
return &user.User{ID: "user-1", Email: email, Name: name, Plan: "free"}, nil
|
||||
},
|
||||
SetRefreshTokenFn: func(ctx context.Context, id, token string, expiresAt time.Time) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
svc := newTestService(verifier, repo)
|
||||
resp, err := svc.Login(context.Background(), "firebase-token")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.AccessToken == "" {
|
||||
t.Error("expected non-empty access token")
|
||||
}
|
||||
if resp.RefreshToken == "" {
|
||||
t.Error("expected non-empty refresh token")
|
||||
}
|
||||
if resp.User.ID != "user-1" {
|
||||
t.Errorf("expected user ID 'user-1', got %q", resp.User.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogin_InvalidFirebaseToken(t *testing.T) {
|
||||
verifier := &mocks.MockTokenVerifier{
|
||||
VerifyTokenFn: func(ctx context.Context, idToken string) (string, string, string, string, error) {
|
||||
return "", "", "", "", fmt.Errorf("invalid token")
|
||||
},
|
||||
}
|
||||
repo := &umocks.MockUserRepository{}
|
||||
|
||||
svc := newTestService(verifier, repo)
|
||||
_, err := svc.Login(context.Background(), "bad-token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid firebase token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogin_UpsertError(t *testing.T) {
|
||||
verifier := &mocks.MockTokenVerifier{
|
||||
VerifyTokenFn: func(ctx context.Context, idToken string) (string, string, string, string, error) {
|
||||
return "fb-uid", "test@example.com", "Test", "", nil
|
||||
},
|
||||
}
|
||||
repo := &umocks.MockUserRepository{
|
||||
UpsertByFirebaseUIDFn: func(ctx context.Context, uid, email, name, avatarURL string) (*user.User, error) {
|
||||
return nil, fmt.Errorf("db error")
|
||||
},
|
||||
}
|
||||
|
||||
svc := newTestService(verifier, repo)
|
||||
_, err := svc.Login(context.Background(), "token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for upsert failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogin_SetRefreshTokenError(t *testing.T) {
|
||||
verifier := &mocks.MockTokenVerifier{
|
||||
VerifyTokenFn: func(ctx context.Context, idToken string) (string, string, string, string, error) {
|
||||
return "fb-uid", "test@example.com", "Test", "", nil
|
||||
},
|
||||
}
|
||||
repo := &umocks.MockUserRepository{
|
||||
UpsertByFirebaseUIDFn: func(ctx context.Context, uid, email, name, avatarURL string) (*user.User, error) {
|
||||
return &user.User{ID: "user-1", Plan: "free"}, nil
|
||||
},
|
||||
SetRefreshTokenFn: func(ctx context.Context, id, token string, expiresAt time.Time) error {
|
||||
return fmt.Errorf("db error")
|
||||
},
|
||||
}
|
||||
|
||||
svc := newTestService(verifier, repo)
|
||||
_, err := svc.Login(context.Background(), "token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for set refresh token failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresh_Success(t *testing.T) {
|
||||
repo := &umocks.MockUserRepository{
|
||||
FindByRefreshTokenFn: func(ctx context.Context, token string) (*user.User, error) {
|
||||
return &user.User{ID: "user-1", Plan: "free"}, nil
|
||||
},
|
||||
SetRefreshTokenFn: func(ctx context.Context, id, token string, expiresAt time.Time) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
verifier := &mocks.MockTokenVerifier{}
|
||||
|
||||
svc := newTestService(verifier, repo)
|
||||
resp, err := svc.Refresh(context.Background(), "valid-refresh-token")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.AccessToken == "" {
|
||||
t.Error("expected non-empty access token")
|
||||
}
|
||||
if resp.RefreshToken == "" {
|
||||
t.Error("expected non-empty refresh token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresh_InvalidToken(t *testing.T) {
|
||||
repo := &umocks.MockUserRepository{
|
||||
FindByRefreshTokenFn: func(ctx context.Context, token string) (*user.User, error) {
|
||||
return nil, fmt.Errorf("not found")
|
||||
},
|
||||
}
|
||||
verifier := &mocks.MockTokenVerifier{}
|
||||
|
||||
svc := newTestService(verifier, repo)
|
||||
_, err := svc.Refresh(context.Background(), "bad-token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid refresh token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresh_SetRefreshTokenError(t *testing.T) {
|
||||
repo := &umocks.MockUserRepository{
|
||||
FindByRefreshTokenFn: func(ctx context.Context, token string) (*user.User, error) {
|
||||
return &user.User{ID: "user-1", Plan: "free"}, nil
|
||||
},
|
||||
SetRefreshTokenFn: func(ctx context.Context, id, token string, expiresAt time.Time) error {
|
||||
return fmt.Errorf("db error")
|
||||
},
|
||||
}
|
||||
verifier := &mocks.MockTokenVerifier{}
|
||||
|
||||
svc := newTestService(verifier, repo)
|
||||
_, err := svc.Refresh(context.Background(), "valid-token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogout_Success(t *testing.T) {
|
||||
repo := &umocks.MockUserRepository{
|
||||
ClearRefreshTokenFn: func(ctx context.Context, id string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
verifier := &mocks.MockTokenVerifier{}
|
||||
|
||||
svc := newTestService(verifier, repo)
|
||||
err := svc.Logout(context.Background(), "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogout_Error(t *testing.T) {
|
||||
repo := &umocks.MockUserRepository{
|
||||
ClearRefreshTokenFn: func(ctx context.Context, id string) error {
|
||||
return fmt.Errorf("db error")
|
||||
},
|
||||
}
|
||||
verifier := &mocks.MockTokenVerifier{}
|
||||
|
||||
svc := newTestService(verifier, repo)
|
||||
err := svc.Logout(context.Background(), "user-1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogin_ExpiresIn(t *testing.T) {
|
||||
verifier := &mocks.MockTokenVerifier{
|
||||
VerifyTokenFn: func(ctx context.Context, idToken string) (string, string, string, string, error) {
|
||||
return "fb-uid", "test@example.com", "Test", "", nil
|
||||
},
|
||||
}
|
||||
repo := &umocks.MockUserRepository{
|
||||
UpsertByFirebaseUIDFn: func(ctx context.Context, uid, email, name, avatarURL string) (*user.User, error) {
|
||||
return &user.User{ID: "user-1", Plan: "free"}, nil
|
||||
},
|
||||
SetRefreshTokenFn: func(ctx context.Context, id, token string, expiresAt time.Time) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
svc := newTestService(verifier, repo)
|
||||
resp, _ := svc.Login(context.Background(), "token")
|
||||
if resp.ExpiresIn != 900 {
|
||||
t.Errorf("expected expires_in 900, got %d", resp.ExpiresIn)
|
||||
}
|
||||
}
|
||||
31
backend/internal/config/config.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/kelseyhightower/envconfig"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port int `envconfig:"PORT" default:"8080"`
|
||||
DatabaseURL string `envconfig:"DATABASE_URL" required:"true"`
|
||||
|
||||
// Firebase
|
||||
FirebaseCredentialsFile string `envconfig:"FIREBASE_CREDENTIALS_FILE" required:"true"`
|
||||
|
||||
// JWT
|
||||
JWTSecret string `envconfig:"JWT_SECRET" required:"true"`
|
||||
JWTAccessDuration time.Duration `envconfig:"JWT_ACCESS_DURATION" default:"15m"`
|
||||
JWTRefreshDuration time.Duration `envconfig:"JWT_REFRESH_DURATION" default:"720h"`
|
||||
|
||||
// CORS
|
||||
AllowedOrigins []string `envconfig:"ALLOWED_ORIGINS" default:"http://localhost:3000"`
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
var cfg Config
|
||||
if err := envconfig.Process("", &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
32
backend/internal/database/postgres.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func NewPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
|
||||
config, err := pgxpool.ParseConfig(databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
|
||||
config.MaxConns = 20
|
||||
config.MinConns = 5
|
||||
config.MaxConnLifetime = 30 * time.Minute
|
||||
config.MaxConnIdleTime = 5 * time.Minute
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create pool: %w", err)
|
||||
}
|
||||
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
return nil, fmt.Errorf("ping: %w", err)
|
||||
}
|
||||
|
||||
return pool, nil
|
||||
}
|
||||
56
backend/internal/middleware/auth.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
userIDKey contextKey = "user_id"
|
||||
userPlanKey contextKey = "user_plan"
|
||||
)
|
||||
|
||||
// TokenClaims represents the result of validating an access token.
|
||||
type TokenClaims struct {
|
||||
UserID string
|
||||
Plan string
|
||||
}
|
||||
|
||||
// AccessTokenValidator validates JWT access tokens.
|
||||
type AccessTokenValidator interface {
|
||||
ValidateAccessToken(tokenStr string) (*TokenClaims, error)
|
||||
}
|
||||
|
||||
func Auth(validator AccessTokenValidator) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
header := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(header, "Bearer ") {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := strings.TrimPrefix(header, "Bearer ")
|
||||
claims, err := validator.ValidateAccessToken(tokenStr)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
|
||||
ctx = context.WithValue(ctx, userPlanKey, claims.Plan)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func UserIDFromCtx(ctx context.Context) string {
|
||||
id, _ := ctx.Value(userIDKey).(string)
|
||||
return id
|
||||
}
|
||||
|
||||
func UserPlanFromCtx(ctx context.Context) string {
|
||||
plan, _ := ctx.Value(userPlanKey).(string)
|
||||
return plan
|
||||
}
|
||||
192
backend/internal/middleware/auth_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// testJWTClaims mirrors auth.Claims for test token generation without importing auth.
|
||||
type testJWTClaims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Plan string `json:"plan"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func generateTestToken(secret string, userID, plan string, duration time.Duration) string {
|
||||
claims := testJWTClaims{
|
||||
UserID: userID,
|
||||
Plan: plan,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(duration)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
s, _ := token.SignedString([]byte(secret))
|
||||
return s
|
||||
}
|
||||
|
||||
// testValidator implements AccessTokenValidator for tests.
|
||||
type testAccessValidator struct {
|
||||
secret string
|
||||
}
|
||||
|
||||
func (v *testAccessValidator) ValidateAccessToken(tokenStr string) (*TokenClaims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &testJWTClaims{}, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method")
|
||||
}
|
||||
return []byte(v.secret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(*testJWTClaims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
return &TokenClaims{UserID: claims.UserID, Plan: claims.Plan}, nil
|
||||
}
|
||||
|
||||
// failingValidator always returns an error.
|
||||
type failingValidator struct{}
|
||||
|
||||
func (v *failingValidator) ValidateAccessToken(tokenStr string) (*TokenClaims, error) {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
func TestAuth_ValidToken(t *testing.T) {
|
||||
validator := &testAccessValidator{secret: "test-secret"}
|
||||
token := generateTestToken("test-secret", "user-1", "free", 15*time.Minute)
|
||||
|
||||
handler := Auth(validator)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
userID := UserIDFromCtx(r.Context())
|
||||
if userID != "user-1" {
|
||||
t.Errorf("expected user-1, got %s", userID)
|
||||
}
|
||||
plan := UserPlanFromCtx(r.Context())
|
||||
if plan != "free" {
|
||||
t.Errorf("expected free, got %s", plan)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuth_MissingHeader(t *testing.T) {
|
||||
validator := &testAccessValidator{secret: "test-secret"}
|
||||
|
||||
handler := Auth(validator)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("handler should not be called")
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuth_InvalidBearerFormat(t *testing.T) {
|
||||
validator := &testAccessValidator{secret: "test-secret"}
|
||||
|
||||
handler := Auth(validator)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("handler should not be called")
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("Authorization", "Basic abc123")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuth_ExpiredToken(t *testing.T) {
|
||||
validator := &testAccessValidator{secret: "test-secret"}
|
||||
token := generateTestToken("test-secret", "user-1", "free", -1*time.Second)
|
||||
|
||||
handler := Auth(validator)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("handler should not be called")
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuth_InvalidToken(t *testing.T) {
|
||||
validator := &testAccessValidator{secret: "test-secret"}
|
||||
|
||||
handler := Auth(validator)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("handler should not be called")
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer invalid-token")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuth_PaidPlan(t *testing.T) {
|
||||
validator := &testAccessValidator{secret: "test-secret"}
|
||||
token := generateTestToken("test-secret", "user-1", "paid", 15*time.Minute)
|
||||
|
||||
handler := Auth(validator)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
plan := UserPlanFromCtx(r.Context())
|
||||
if plan != "paid" {
|
||||
t.Errorf("expected paid, got %s", plan)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuth_EmptyBearer(t *testing.T) {
|
||||
handler := Auth(&failingValidator{})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("handler should not be called")
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer ")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
18
backend/internal/middleware/cors.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/cors"
|
||||
)
|
||||
|
||||
func CORS(allowedOrigins []string) func(http.Handler) http.Handler {
|
||||
return cors.Handler(cors.Options{
|
||||
AllowedOrigins: allowedOrigins,
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Authorization", "Content-Type", "X-Request-ID"},
|
||||
ExposedHeaders: []string{"X-Request-ID"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
})
|
||||
}
|
||||
34
backend/internal/middleware/logging.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.statusCode = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func Logging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
ww := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
|
||||
next.ServeHTTP(ww, r)
|
||||
|
||||
slog.Info("request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", ww.statusCode,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
"request_id", RequestIDFromCtx(r.Context()),
|
||||
)
|
||||
})
|
||||
}
|
||||
23
backend/internal/middleware/recovery.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
)
|
||||
|
||||
func Recovery(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
slog.Error("panic recovered",
|
||||
"error", err,
|
||||
"stack", string(debug.Stack()),
|
||||
"request_id", RequestIDFromCtx(r.Context()),
|
||||
)
|
||||
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
49
backend/internal/middleware/recovery_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRecovery_NoPanic(t *testing.T) {
|
||||
handler := Recovery(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecovery_CatchesPanic(t *testing.T) {
|
||||
handler := Recovery(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
panic("test panic")
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecovery_CatchesPanicWithError(t *testing.T) {
|
||||
handler := Recovery(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
panic(42)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
29
backend/internal/middleware/request_id.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const requestIDKey contextKey = "request_id"
|
||||
|
||||
func RequestID(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.Header.Get("X-Request-ID")
|
||||
if id == "" {
|
||||
id = uuid.NewString()
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), requestIDKey, id)
|
||||
w.Header().Set("X-Request-ID", id)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func RequestIDFromCtx(ctx context.Context) string {
|
||||
id, _ := ctx.Value(requestIDKey).(string)
|
||||
return id
|
||||
}
|
||||
59
backend/internal/middleware/request_id_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRequestID_GeneratesNew(t *testing.T) {
|
||||
handler := RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := RequestIDFromCtx(r.Context())
|
||||
if id == "" {
|
||||
t.Error("expected non-empty request ID in context")
|
||||
}
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Header().Get("X-Request-ID") == "" {
|
||||
t.Error("expected X-Request-ID in response header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestID_PreservesExisting(t *testing.T) {
|
||||
handler := RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := RequestIDFromCtx(r.Context())
|
||||
if id != "existing-id" {
|
||||
t.Errorf("expected 'existing-id', got %q", id)
|
||||
}
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("X-Request-ID", "existing-id")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Header().Get("X-Request-ID") != "existing-id" {
|
||||
t.Error("expected preserved X-Request-ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestID_UniquePerRequest(t *testing.T) {
|
||||
var ids []string
|
||||
handler := RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ids = append(ids, RequestIDFromCtx(r.Context()))
|
||||
}))
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
}
|
||||
|
||||
if ids[0] == ids[1] || ids[1] == ids[2] {
|
||||
t.Error("expected unique IDs for each request")
|
||||
}
|
||||
}
|
||||
61
backend/internal/server/server.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/food-ai/backend/internal/auth"
|
||||
"github.com/food-ai/backend/internal/middleware"
|
||||
"github.com/food-ai/backend/internal/user"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func NewRouter(
|
||||
pool *pgxpool.Pool,
|
||||
authHandler *auth.Handler,
|
||||
userHandler *user.Handler,
|
||||
authMiddleware func(http.Handler) http.Handler,
|
||||
allowedOrigins []string,
|
||||
) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Global middleware
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.Logging)
|
||||
r.Use(middleware.Recovery)
|
||||
r.Use(middleware.CORS(allowedOrigins))
|
||||
|
||||
// Public
|
||||
r.Get("/health", healthCheck(pool))
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Post("/login", authHandler.Login)
|
||||
r.Post("/refresh", authHandler.Refresh)
|
||||
r.Post("/logout", authHandler.Logout)
|
||||
})
|
||||
|
||||
// Protected
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authMiddleware)
|
||||
r.Get("/profile", userHandler.Get)
|
||||
r.Put("/profile", userHandler.Update)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func healthCheck(pool *pgxpool.Pool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
dbStatus := "connected"
|
||||
if err := pool.Ping(r.Context()); err != nil {
|
||||
dbStatus = "disconnected"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "ok",
|
||||
"version": "0.1.0",
|
||||
"db": dbStatus,
|
||||
})
|
||||
}
|
||||
}
|
||||
77
backend/internal/testutil/postgres.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/pressly/goose/v3"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
)
|
||||
|
||||
func SetupTestDB(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
||||
ContainerRequest: testcontainers.ContainerRequest{
|
||||
Image: "postgres:16-alpine",
|
||||
ExposedPorts: []string{"5432/tcp"},
|
||||
Env: map[string]string{
|
||||
"POSTGRES_DB": "test_db",
|
||||
"POSTGRES_USER": "test_user",
|
||||
"POSTGRES_PASSWORD": "test_pass",
|
||||
},
|
||||
WaitingFor: wait.ForListeningPort("5432/tcp"),
|
||||
},
|
||||
Started: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to start container: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
container.Terminate(ctx)
|
||||
})
|
||||
|
||||
host, err := container.Host(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get host: %v", err)
|
||||
}
|
||||
|
||||
port, err := container.MappedPort(ctx, "5432")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get port: %v", err)
|
||||
}
|
||||
|
||||
dsn := fmt.Sprintf("postgres://test_user:test_pass@%s:%s/test_db?sslmode=disable", host, port.Port())
|
||||
|
||||
// Run migrations with goose
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open db for migrations: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := goose.Up(db, "../../../migrations"); err != nil {
|
||||
// Try relative path from different test locations
|
||||
if err2 := goose.Up(db, "../../migrations"); err2 != nil {
|
||||
t.Fatalf("failed to run migrations: %v (also tried: %v)", err, err2)
|
||||
}
|
||||
}
|
||||
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create pool: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
pool.Close()
|
||||
})
|
||||
|
||||
return pool
|
||||
}
|
||||
51
backend/internal/user/calories.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package user
|
||||
|
||||
import "math"
|
||||
|
||||
// Activity level multipliers (Mifflin-St Jeor).
|
||||
var activityMultiplier = map[string]float64{
|
||||
"low": 1.375,
|
||||
"moderate": 1.55,
|
||||
"high": 1.725,
|
||||
}
|
||||
|
||||
// Goal adjustments in kcal.
|
||||
var goalAdjustment = map[string]float64{
|
||||
"lose": -500,
|
||||
"maintain": 0,
|
||||
"gain": 300,
|
||||
}
|
||||
|
||||
// CalculateDailyCalories computes the daily calorie target using the
|
||||
// Mifflin-St Jeor equation. Returns nil if any required parameter is missing.
|
||||
func CalculateDailyCalories(heightCM *int, weightKG *float64, age *int, gender, activity, goal *string) *int {
|
||||
if heightCM == nil || weightKG == nil || age == nil || gender == nil || activity == nil || goal == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// BMR = 10 * weight(kg) + 6.25 * height(cm) - 5 * age
|
||||
bmr := 10**weightKG + 6.25*float64(*heightCM) - 5*float64(*age)
|
||||
|
||||
switch *gender {
|
||||
case "male":
|
||||
bmr += 5
|
||||
case "female":
|
||||
bmr -= 161
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
mult, ok := activityMultiplier[*activity]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
adj, ok := goalAdjustment[*goal]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
tdee := bmr * mult
|
||||
result := int(math.Round(tdee + adj))
|
||||
return &result
|
||||
}
|
||||
73
backend/internal/user/calories_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func TestCalculateDailyCalories_MaleMaintain(t *testing.T) {
|
||||
cal := CalculateDailyCalories(ptr(180), ptr(80.0), ptr(30), ptr("male"), ptr("moderate"), ptr("maintain"))
|
||||
if cal == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
// BMR = 10*80 + 6.25*180 - 5*30 + 5 = 800 + 1125 - 150 + 5 = 1780
|
||||
// TDEE = 1780 * 1.55 = 2759
|
||||
if *cal != 2759 {
|
||||
t.Errorf("expected 2759, got %d", *cal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateDailyCalories_FemaleLose(t *testing.T) {
|
||||
cal := CalculateDailyCalories(ptr(165), ptr(60.0), ptr(25), ptr("female"), ptr("low"), ptr("lose"))
|
||||
if cal == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
// BMR = 10*60 + 6.25*165 - 5*25 - 161 = 600 + 1031.25 - 125 - 161 = 1345.25
|
||||
// TDEE = 1345.25 * 1.375 = 1849.72
|
||||
// Goal: -500 = 1349.72 → 1350
|
||||
if *cal != 1350 {
|
||||
t.Errorf("expected 1350, got %d", *cal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateDailyCalories_MaleGain(t *testing.T) {
|
||||
cal := CalculateDailyCalories(ptr(175), ptr(70.0), ptr(28), ptr("male"), ptr("high"), ptr("gain"))
|
||||
if cal == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
// BMR = 10*70 + 6.25*175 - 5*28 + 5 = 700 + 1093.75 - 140 + 5 = 1658.75
|
||||
// TDEE = 1658.75 * 1.725 = 2861.34
|
||||
// Goal: +300 = 3161.34 → 3161
|
||||
if *cal != 3161 {
|
||||
t.Errorf("expected 3161, got %d", *cal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateDailyCalories_NilHeight(t *testing.T) {
|
||||
cal := CalculateDailyCalories(nil, ptr(80.0), ptr(30), ptr("male"), ptr("moderate"), ptr("maintain"))
|
||||
if cal != nil {
|
||||
t.Fatal("expected nil when height is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateDailyCalories_NilWeight(t *testing.T) {
|
||||
cal := CalculateDailyCalories(ptr(180), nil, ptr(30), ptr("male"), ptr("moderate"), ptr("maintain"))
|
||||
if cal != nil {
|
||||
t.Fatal("expected nil when weight is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateDailyCalories_InvalidGender(t *testing.T) {
|
||||
cal := CalculateDailyCalories(ptr(180), ptr(80.0), ptr(30), ptr("other"), ptr("moderate"), ptr("maintain"))
|
||||
if cal != nil {
|
||||
t.Fatal("expected nil for invalid gender")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateDailyCalories_InvalidActivity(t *testing.T) {
|
||||
cal := CalculateDailyCalories(ptr(180), ptr(80.0), ptr(30), ptr("male"), ptr("extreme"), ptr("maintain"))
|
||||
if cal != nil {
|
||||
t.Fatal("expected nil for invalid activity")
|
||||
}
|
||||
}
|
||||
78
backend/internal/user/handler.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/food-ai/backend/internal/middleware"
|
||||
)
|
||||
|
||||
const maxRequestBodySize = 1 << 20 // 1 MB
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
u, err := h.service.GetProfile(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeErrorJSON(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
|
||||
var req UpdateProfileRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
u, err := h.service.UpdateProfile(r.Context(), userID, req)
|
||||
if err != nil {
|
||||
writeErrorJSON(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func writeErrorJSON(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil {
|
||||
slog.Error("failed to write error response", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
slog.Error("failed to write JSON response", "err", err)
|
||||
}
|
||||
}
|
||||
46
backend/internal/user/mocks/repository.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/food-ai/backend/internal/user"
|
||||
)
|
||||
|
||||
type MockUserRepository struct {
|
||||
UpsertByFirebaseUIDFn func(ctx context.Context, uid, email, name, avatarURL string) (*user.User, error)
|
||||
GetByIDFn func(ctx context.Context, id string) (*user.User, error)
|
||||
UpdateFn func(ctx context.Context, id string, req user.UpdateProfileRequest) (*user.User, error)
|
||||
UpdateInTxFn func(ctx context.Context, id string, profileReq user.UpdateProfileRequest, caloriesReq *user.UpdateProfileRequest) (*user.User, error)
|
||||
SetRefreshTokenFn func(ctx context.Context, id, token string, expiresAt time.Time) error
|
||||
FindByRefreshTokenFn func(ctx context.Context, token string) (*user.User, error)
|
||||
ClearRefreshTokenFn func(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) UpsertByFirebaseUID(ctx context.Context, uid, email, name, avatarURL string) (*user.User, error) {
|
||||
return m.UpsertByFirebaseUIDFn(ctx, uid, email, name, avatarURL)
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) GetByID(ctx context.Context, id string) (*user.User, error) {
|
||||
return m.GetByIDFn(ctx, id)
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) Update(ctx context.Context, id string, req user.UpdateProfileRequest) (*user.User, error) {
|
||||
return m.UpdateFn(ctx, id, req)
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) UpdateInTx(ctx context.Context, id string, profileReq user.UpdateProfileRequest, caloriesReq *user.UpdateProfileRequest) (*user.User, error) {
|
||||
return m.UpdateInTxFn(ctx, id, profileReq, caloriesReq)
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) SetRefreshToken(ctx context.Context, id, token string, expiresAt time.Time) error {
|
||||
return m.SetRefreshTokenFn(ctx, id, token, expiresAt)
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) FindByRefreshToken(ctx context.Context, token string) (*user.User, error) {
|
||||
return m.FindByRefreshTokenFn(ctx, token)
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) ClearRefreshToken(ctx context.Context, id string) error {
|
||||
return m.ClearRefreshTokenFn(ctx, id)
|
||||
}
|
||||
44
backend/internal/user/model.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
FirebaseUID string `json:"-"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
HeightCM *int `json:"height_cm"`
|
||||
WeightKG *float64 `json:"weight_kg"`
|
||||
Age *int `json:"age"`
|
||||
Gender *string `json:"gender"`
|
||||
Activity *string `json:"activity"`
|
||||
Goal *string `json:"goal"`
|
||||
DailyCalories *int `json:"daily_calories"`
|
||||
Plan string `json:"plan"`
|
||||
Preferences json.RawMessage `json:"preferences"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type UpdateProfileRequest struct {
|
||||
Name *string `json:"name"`
|
||||
HeightCM *int `json:"height_cm"`
|
||||
WeightKG *float64 `json:"weight_kg"`
|
||||
Age *int `json:"age"`
|
||||
Gender *string `json:"gender"`
|
||||
Activity *string `json:"activity"`
|
||||
Goal *string `json:"goal"`
|
||||
Preferences *json.RawMessage `json:"preferences"`
|
||||
DailyCalories *int `json:"-"` // internal, set by service
|
||||
}
|
||||
|
||||
// HasBodyParams returns true if any body parameter is being updated
|
||||
// that would require recalculation of daily calories.
|
||||
func (r *UpdateProfileRequest) HasBodyParams() bool {
|
||||
return r.HeightCM != nil || r.WeightKG != nil || r.Age != nil ||
|
||||
r.Gender != nil || r.Activity != nil || r.Goal != nil
|
||||
}
|
||||
218
backend/internal/user/repository.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// UserRepository defines the persistence interface for user data.
|
||||
type UserRepository interface {
|
||||
UpsertByFirebaseUID(ctx context.Context, uid, email, name, avatarURL string) (*User, error)
|
||||
GetByID(ctx context.Context, id string) (*User, error)
|
||||
Update(ctx context.Context, id string, req UpdateProfileRequest) (*User, error)
|
||||
UpdateInTx(ctx context.Context, id string, profileReq UpdateProfileRequest, caloriesReq *UpdateProfileRequest) (*User, error)
|
||||
SetRefreshToken(ctx context.Context, id, token string, expiresAt time.Time) error
|
||||
FindByRefreshToken(ctx context.Context, token string) (*User, error)
|
||||
ClearRefreshToken(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
type Repository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewRepository(pool *pgxpool.Pool) *Repository {
|
||||
return &Repository{pool: pool}
|
||||
}
|
||||
|
||||
func (r *Repository) UpsertByFirebaseUID(ctx context.Context, uid, email, name, avatarURL string) (*User, error) {
|
||||
var avatarPtr *string
|
||||
if avatarURL != "" {
|
||||
avatarPtr = &avatarURL
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO users (firebase_uid, email, name, avatar_url)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (firebase_uid) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
name = CASE WHEN users.name = '' THEN EXCLUDED.name ELSE users.name END,
|
||||
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
|
||||
updated_at = now()
|
||||
RETURNING id, firebase_uid, email, name, avatar_url,
|
||||
height_cm, weight_kg, age, gender, activity, goal, daily_calories,
|
||||
plan, preferences, created_at, updated_at`
|
||||
|
||||
return r.scanUser(r.pool.QueryRow(ctx, query, uid, email, name, avatarPtr))
|
||||
}
|
||||
|
||||
func (r *Repository) GetByID(ctx context.Context, id string) (*User, error) {
|
||||
query := `
|
||||
SELECT id, firebase_uid, email, name, avatar_url,
|
||||
height_cm, weight_kg, age, gender, activity, goal, daily_calories,
|
||||
plan, preferences, created_at, updated_at
|
||||
FROM users WHERE id = $1`
|
||||
|
||||
return r.scanUser(r.pool.QueryRow(ctx, query, id))
|
||||
}
|
||||
|
||||
func (r *Repository) Update(ctx context.Context, id string, req UpdateProfileRequest) (*User, error) {
|
||||
query, args := buildUpdateQuery(id, req)
|
||||
if query == "" {
|
||||
return r.GetByID(ctx, id)
|
||||
}
|
||||
return r.scanUser(r.pool.QueryRow(ctx, query, args...))
|
||||
}
|
||||
|
||||
// buildUpdateQuery constructs a dynamic UPDATE query from the request fields.
|
||||
// Returns empty string if there are no fields to update.
|
||||
func buildUpdateQuery(id string, req UpdateProfileRequest) (string, []interface{}) {
|
||||
setClauses := []string{}
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
if req.Name != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("name = $%d", argIdx))
|
||||
args = append(args, *req.Name)
|
||||
argIdx++
|
||||
}
|
||||
if req.HeightCM != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("height_cm = $%d", argIdx))
|
||||
args = append(args, *req.HeightCM)
|
||||
argIdx++
|
||||
}
|
||||
if req.WeightKG != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("weight_kg = $%d", argIdx))
|
||||
args = append(args, *req.WeightKG)
|
||||
argIdx++
|
||||
}
|
||||
if req.Age != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("age = $%d", argIdx))
|
||||
args = append(args, *req.Age)
|
||||
argIdx++
|
||||
}
|
||||
if req.Gender != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("gender = $%d", argIdx))
|
||||
args = append(args, *req.Gender)
|
||||
argIdx++
|
||||
}
|
||||
if req.Activity != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("activity = $%d", argIdx))
|
||||
args = append(args, *req.Activity)
|
||||
argIdx++
|
||||
}
|
||||
if req.Goal != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("goal = $%d", argIdx))
|
||||
args = append(args, *req.Goal)
|
||||
argIdx++
|
||||
}
|
||||
if req.Preferences != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("preferences = $%d", argIdx))
|
||||
args = append(args, string(*req.Preferences))
|
||||
argIdx++
|
||||
}
|
||||
if req.DailyCalories != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("daily_calories = $%d", argIdx))
|
||||
args = append(args, *req.DailyCalories)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
setClauses = append(setClauses, "updated_at = now()")
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
UPDATE users SET %s
|
||||
WHERE id = $%d
|
||||
RETURNING id, firebase_uid, email, name, avatar_url,
|
||||
height_cm, weight_kg, age, gender, activity, goal, daily_calories,
|
||||
plan, preferences, created_at, updated_at`,
|
||||
strings.Join(setClauses, ", "), argIdx)
|
||||
args = append(args, id)
|
||||
|
||||
return query, args
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateInTx(ctx context.Context, id string, profileReq UpdateProfileRequest, caloriesReq *UpdateProfileRequest) (*User, error) {
|
||||
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// First update: profile fields
|
||||
query, args := buildUpdateQuery(id, profileReq)
|
||||
if query == "" {
|
||||
return nil, fmt.Errorf("no fields to update")
|
||||
}
|
||||
updated, err := r.scanUser(tx.QueryRow(ctx, query, args...))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update profile: %w", err)
|
||||
}
|
||||
|
||||
// Second update: calories (if provided)
|
||||
if caloriesReq != nil {
|
||||
query, args = buildUpdateQuery(id, *caloriesReq)
|
||||
if query != "" {
|
||||
updated, err = r.scanUser(tx.QueryRow(ctx, query, args...))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update calories: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (r *Repository) SetRefreshToken(ctx context.Context, id, token string, expiresAt time.Time) error {
|
||||
query := `UPDATE users SET refresh_token = $2, token_expires_at = $3, updated_at = now() WHERE id = $1`
|
||||
_, err := r.pool.Exec(ctx, query, id, token, expiresAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) FindByRefreshToken(ctx context.Context, token string) (*User, error) {
|
||||
query := `
|
||||
SELECT id, firebase_uid, email, name, avatar_url,
|
||||
height_cm, weight_kg, age, gender, activity, goal, daily_calories,
|
||||
plan, preferences, created_at, updated_at
|
||||
FROM users
|
||||
WHERE refresh_token = $1 AND token_expires_at > now()`
|
||||
|
||||
return r.scanUser(r.pool.QueryRow(ctx, query, token))
|
||||
}
|
||||
|
||||
func (r *Repository) ClearRefreshToken(ctx context.Context, id string) error {
|
||||
query := `UPDATE users SET refresh_token = NULL, token_expires_at = NULL, updated_at = now() WHERE id = $1`
|
||||
_, err := r.pool.Exec(ctx, query, id)
|
||||
return err
|
||||
}
|
||||
|
||||
type scannable interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}
|
||||
|
||||
func (r *Repository) scanUser(row scannable) (*User, error) {
|
||||
var u User
|
||||
var prefs []byte
|
||||
err := row.Scan(
|
||||
&u.ID, &u.FirebaseUID, &u.Email, &u.Name, &u.AvatarURL,
|
||||
&u.HeightCM, &u.WeightKG, &u.Age, &u.Gender, &u.Activity, &u.Goal, &u.DailyCalories,
|
||||
&u.Plan, &prefs, &u.CreatedAt, &u.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan user: %w", err)
|
||||
}
|
||||
u.Preferences = json.RawMessage(prefs)
|
||||
return &u, nil
|
||||
}
|
||||
209
backend/internal/user/repository_integration_test.go
Normal file
@@ -0,0 +1,209 @@
|
||||
//go:build integration
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/food-ai/backend/internal/testutil"
|
||||
)
|
||||
|
||||
func TestRepository_UpsertByFirebaseUID_Insert(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
u, err := repo.UpsertByFirebaseUID(ctx, "fb-123", "test@example.com", "Test User", "https://avatar.url")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if u.FirebaseUID != "fb-123" {
|
||||
t.Errorf("expected fb-123, got %s", u.FirebaseUID)
|
||||
}
|
||||
if u.Email != "test@example.com" {
|
||||
t.Errorf("expected test@example.com, got %s", u.Email)
|
||||
}
|
||||
if u.Plan != "free" {
|
||||
t.Errorf("expected free, got %s", u.Plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepository_UpsertByFirebaseUID_Update(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
_, _ = repo.UpsertByFirebaseUID(ctx, "fb-123", "old@example.com", "Old Name", "")
|
||||
u, err := repo.UpsertByFirebaseUID(ctx, "fb-123", "new@example.com", "New Name", "https://new-avatar.url")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if u.Email != "new@example.com" {
|
||||
t.Errorf("expected new email, got %s", u.Email)
|
||||
}
|
||||
// Name should not be overwritten if already set
|
||||
if u.Name != "Old Name" {
|
||||
t.Errorf("expected name to be preserved as 'Old Name', got %s", u.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepository_GetByID(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
created, _ := repo.UpsertByFirebaseUID(ctx, "fb-get", "get@example.com", "Get User", "")
|
||||
u, err := repo.GetByID(ctx, created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if u.ID != created.ID {
|
||||
t.Errorf("expected %s, got %s", created.ID, u.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepository_GetByID_NotFound(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := repo.GetByID(ctx, "00000000-0000-0000-0000-000000000000")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepository_Update(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
created, _ := repo.UpsertByFirebaseUID(ctx, "fb-upd", "upd@example.com", "Update User", "")
|
||||
height := 180
|
||||
u, err := repo.Update(ctx, created.ID, UpdateProfileRequest{HeightCM: &height})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if u.HeightCM == nil || *u.HeightCM != 180 {
|
||||
t.Errorf("expected height 180, got %v", u.HeightCM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepository_Update_MultipleFields(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
created, _ := repo.UpsertByFirebaseUID(ctx, "fb-multi", "multi@example.com", "Multi User", "")
|
||||
height := 175
|
||||
weight := 70.5
|
||||
age := 25
|
||||
gender := "male"
|
||||
activity := "moderate"
|
||||
goal := "maintain"
|
||||
u, err := repo.Update(ctx, created.ID, UpdateProfileRequest{
|
||||
HeightCM: &height,
|
||||
WeightKG: &weight,
|
||||
Age: &age,
|
||||
Gender: &gender,
|
||||
Activity: &activity,
|
||||
Goal: &goal,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if u.HeightCM == nil || *u.HeightCM != 175 {
|
||||
t.Errorf("expected 175, got %v", u.HeightCM)
|
||||
}
|
||||
if u.WeightKG == nil || *u.WeightKG != 70.5 {
|
||||
t.Errorf("expected 70.5, got %v", u.WeightKG)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepository_Update_Preferences(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
created, _ := repo.UpsertByFirebaseUID(ctx, "fb-prefs", "prefs@example.com", "Prefs User", "")
|
||||
prefs := json.RawMessage(`{"cuisines":["russian","asian"]}`)
|
||||
u, err := repo.Update(ctx, created.ID, UpdateProfileRequest{Preferences: &prefs})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var p map[string]interface{}
|
||||
json.Unmarshal(u.Preferences, &p)
|
||||
if p["cuisines"] == nil {
|
||||
t.Error("expected cuisines in preferences")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepository_SetAndFindRefreshToken(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
created, _ := repo.UpsertByFirebaseUID(ctx, "fb-token", "token@example.com", "Token User", "")
|
||||
err := repo.SetRefreshToken(ctx, created.ID, "refresh-token-123", time.Now().Add(24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error setting token: %v", err)
|
||||
}
|
||||
|
||||
u, err := repo.FindByRefreshToken(ctx, "refresh-token-123")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error finding by token: %v", err)
|
||||
}
|
||||
if u.ID != created.ID {
|
||||
t.Errorf("expected %s, got %s", created.ID, u.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepository_FindByRefreshToken_Expired(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
created, _ := repo.UpsertByFirebaseUID(ctx, "fb-expired", "expired@example.com", "Expired User", "")
|
||||
_ = repo.SetRefreshToken(ctx, created.ID, "expired-token", time.Now().Add(-1*time.Hour))
|
||||
|
||||
_, err := repo.FindByRefreshToken(ctx, "expired-token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for expired token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepository_ClearRefreshToken(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
created, _ := repo.UpsertByFirebaseUID(ctx, "fb-clear", "clear@example.com", "Clear User", "")
|
||||
_ = repo.SetRefreshToken(ctx, created.ID, "token-to-clear", time.Now().Add(24*time.Hour))
|
||||
err := repo.ClearRefreshToken(ctx, created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
_, err = repo.FindByRefreshToken(ctx, "token-to-clear")
|
||||
if err == nil {
|
||||
t.Fatal("expected error after clearing token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepository_Update_NoFields(t *testing.T) {
|
||||
pool := testutil.SetupTestDB(t)
|
||||
repo := NewRepository(pool)
|
||||
ctx := context.Background()
|
||||
|
||||
created, _ := repo.UpsertByFirebaseUID(ctx, "fb-noop", "noop@example.com", "Noop User", "")
|
||||
u, err := repo.Update(ctx, created.ID, UpdateProfileRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if u.ID != created.ID {
|
||||
t.Errorf("expected %s, got %s", created.ID, u.ID)
|
||||
}
|
||||
}
|
||||
117
backend/internal/user/service.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo UserRepository
|
||||
}
|
||||
|
||||
func NewService(repo UserRepository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) GetProfile(ctx context.Context, userID string) (*User, error) {
|
||||
return s.repo.GetByID(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateProfile(ctx context.Context, userID string, req UpdateProfileRequest) (*User, error) {
|
||||
if err := validateProfileRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !req.HasBodyParams() {
|
||||
updated, err := s.repo.Update(ctx, userID, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update profile: %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// Need to update profile + recalculate calories in a single transaction.
|
||||
// First, get current user to merge with incoming fields for calorie calculation.
|
||||
current, err := s.repo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
|
||||
// Merge current values with request to compute calories
|
||||
height := current.HeightCM
|
||||
if req.HeightCM != nil {
|
||||
height = req.HeightCM
|
||||
}
|
||||
weight := current.WeightKG
|
||||
if req.WeightKG != nil {
|
||||
weight = req.WeightKG
|
||||
}
|
||||
age := current.Age
|
||||
if req.Age != nil {
|
||||
age = req.Age
|
||||
}
|
||||
gender := current.Gender
|
||||
if req.Gender != nil {
|
||||
gender = req.Gender
|
||||
}
|
||||
activity := current.Activity
|
||||
if req.Activity != nil {
|
||||
activity = req.Activity
|
||||
}
|
||||
goal := current.Goal
|
||||
if req.Goal != nil {
|
||||
goal = req.Goal
|
||||
}
|
||||
|
||||
calories := CalculateDailyCalories(height, weight, age, gender, activity, goal)
|
||||
|
||||
var calReq *UpdateProfileRequest
|
||||
if calories != nil {
|
||||
calReq = &UpdateProfileRequest{DailyCalories: calories}
|
||||
}
|
||||
|
||||
updated, err := s.repo.UpdateInTx(ctx, userID, req, calReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update profile: %w", err)
|
||||
}
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func validateProfileRequest(req UpdateProfileRequest) error {
|
||||
if req.HeightCM != nil {
|
||||
if *req.HeightCM < 100 || *req.HeightCM > 250 {
|
||||
return fmt.Errorf("height_cm must be between 100 and 250")
|
||||
}
|
||||
}
|
||||
if req.WeightKG != nil {
|
||||
if *req.WeightKG < 30 || *req.WeightKG > 300 {
|
||||
return fmt.Errorf("weight_kg must be between 30 and 300")
|
||||
}
|
||||
}
|
||||
if req.Age != nil {
|
||||
if *req.Age < 10 || *req.Age > 120 {
|
||||
return fmt.Errorf("age must be between 10 and 120")
|
||||
}
|
||||
}
|
||||
if req.Gender != nil {
|
||||
if *req.Gender != "male" && *req.Gender != "female" {
|
||||
return fmt.Errorf("gender must be 'male' or 'female'")
|
||||
}
|
||||
}
|
||||
if req.Activity != nil {
|
||||
switch *req.Activity {
|
||||
case "low", "moderate", "high":
|
||||
default:
|
||||
return fmt.Errorf("activity must be 'low', 'moderate', or 'high'")
|
||||
}
|
||||
}
|
||||
if req.Goal != nil {
|
||||
switch *req.Goal {
|
||||
case "lose", "maintain", "gain":
|
||||
default:
|
||||
return fmt.Errorf("goal must be 'lose', 'maintain', or 'gain'")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
247
backend/internal/user/service_test.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ptrStr(s string) *string { return &s }
|
||||
func ptrInt(i int) *int { return &i }
|
||||
func ptrFloat(f float64) *float64 { return &f }
|
||||
|
||||
// mockUserRepo is an in-package mock to avoid import cycles.
|
||||
type mockUserRepo struct {
|
||||
getByIDFn func(ctx context.Context, id string) (*User, error)
|
||||
updateFn func(ctx context.Context, id string, req UpdateProfileRequest) (*User, error)
|
||||
updateInTxFn func(ctx context.Context, id string, profileReq UpdateProfileRequest, caloriesReq *UpdateProfileRequest) (*User, error)
|
||||
upsertByFirebaseUIDFn func(ctx context.Context, uid, email, name, avatarURL string) (*User, error)
|
||||
setRefreshTokenFn func(ctx context.Context, id, token string, expiresAt time.Time) error
|
||||
findByRefreshTokenFn func(ctx context.Context, token string) (*User, error)
|
||||
clearRefreshTokenFn func(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) UpsertByFirebaseUID(ctx context.Context, uid, email, name, avatarURL string) (*User, error) {
|
||||
return m.upsertByFirebaseUIDFn(ctx, uid, email, name, avatarURL)
|
||||
}
|
||||
func (m *mockUserRepo) GetByID(ctx context.Context, id string) (*User, error) {
|
||||
return m.getByIDFn(ctx, id)
|
||||
}
|
||||
func (m *mockUserRepo) Update(ctx context.Context, id string, req UpdateProfileRequest) (*User, error) {
|
||||
return m.updateFn(ctx, id, req)
|
||||
}
|
||||
func (m *mockUserRepo) UpdateInTx(ctx context.Context, id string, profileReq UpdateProfileRequest, caloriesReq *UpdateProfileRequest) (*User, error) {
|
||||
return m.updateInTxFn(ctx, id, profileReq, caloriesReq)
|
||||
}
|
||||
func (m *mockUserRepo) SetRefreshToken(ctx context.Context, id, token string, expiresAt time.Time) error {
|
||||
return m.setRefreshTokenFn(ctx, id, token, expiresAt)
|
||||
}
|
||||
func (m *mockUserRepo) FindByRefreshToken(ctx context.Context, token string) (*User, error) {
|
||||
return m.findByRefreshTokenFn(ctx, token)
|
||||
}
|
||||
func (m *mockUserRepo) ClearRefreshToken(ctx context.Context, id string) error {
|
||||
return m.clearRefreshTokenFn(ctx, id)
|
||||
}
|
||||
|
||||
func TestGetProfile_Success(t *testing.T) {
|
||||
repo := &mockUserRepo{
|
||||
getByIDFn: func(ctx context.Context, id string) (*User, error) {
|
||||
return &User{ID: id, Email: "test@example.com", Plan: "free"}, nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
u, err := svc.GetProfile(context.Background(), "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if u.ID != "user-1" {
|
||||
t.Errorf("expected user-1, got %s", u.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProfile_NotFound(t *testing.T) {
|
||||
repo := &mockUserRepo{
|
||||
getByIDFn: func(ctx context.Context, id string) (*User, error) {
|
||||
return nil, fmt.Errorf("not found")
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
_, err := svc.GetProfile(context.Background(), "nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProfile_NameOnly(t *testing.T) {
|
||||
repo := &mockUserRepo{
|
||||
updateFn: func(ctx context.Context, id string, req UpdateProfileRequest) (*User, error) {
|
||||
return &User{ID: id, Name: *req.Name, Plan: "free"}, nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
u, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{Name: ptrStr("New Name")})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if u.Name != "New Name" {
|
||||
t.Errorf("expected 'New Name', got %q", u.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProfile_WithCaloriesRecalculation(t *testing.T) {
|
||||
profileReq := UpdateProfileRequest{
|
||||
HeightCM: ptrInt(180),
|
||||
WeightKG: ptrFloat(80),
|
||||
Age: ptrInt(30),
|
||||
Gender: ptrStr("male"),
|
||||
Activity: ptrStr("moderate"),
|
||||
Goal: ptrStr("maintain"),
|
||||
}
|
||||
finalUser := &User{
|
||||
ID: "user-1",
|
||||
HeightCM: ptrInt(180),
|
||||
WeightKG: ptrFloat(80),
|
||||
Age: ptrInt(30),
|
||||
Gender: ptrStr("male"),
|
||||
Activity: ptrStr("moderate"),
|
||||
Goal: ptrStr("maintain"),
|
||||
DailyCalories: ptrInt(2759),
|
||||
Plan: "free",
|
||||
}
|
||||
|
||||
repo := &mockUserRepo{
|
||||
// Service calls GetByID first to merge current values for calorie calculation
|
||||
getByIDFn: func(ctx context.Context, id string) (*User, error) {
|
||||
return &User{ID: id, Plan: "free"}, nil
|
||||
},
|
||||
updateInTxFn: func(ctx context.Context, id string, pReq UpdateProfileRequest, cReq *UpdateProfileRequest) (*User, error) {
|
||||
if cReq == nil {
|
||||
t.Error("expected caloriesReq to be non-nil")
|
||||
} else if *cReq.DailyCalories != 2759 {
|
||||
t.Errorf("expected calories 2759, got %d", *cReq.DailyCalories)
|
||||
}
|
||||
return finalUser, nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
u, err := svc.UpdateProfile(context.Background(), "user-1", profileReq)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if u.DailyCalories == nil {
|
||||
t.Fatal("expected daily_calories to be set")
|
||||
}
|
||||
if *u.DailyCalories != 2759 {
|
||||
t.Errorf("expected 2759, got %d", *u.DailyCalories)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProfile_InvalidHeight_TooLow(t *testing.T) {
|
||||
svc := NewService(&mockUserRepo{})
|
||||
|
||||
_, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{HeightCM: ptrInt(50)})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProfile_InvalidHeight_TooHigh(t *testing.T) {
|
||||
svc := NewService(&mockUserRepo{})
|
||||
|
||||
_, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{HeightCM: ptrInt(300)})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProfile_InvalidWeight_TooLow(t *testing.T) {
|
||||
svc := NewService(&mockUserRepo{})
|
||||
|
||||
_, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{WeightKG: ptrFloat(10)})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProfile_InvalidWeight_TooHigh(t *testing.T) {
|
||||
svc := NewService(&mockUserRepo{})
|
||||
|
||||
_, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{WeightKG: ptrFloat(400)})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProfile_InvalidAge_TooLow(t *testing.T) {
|
||||
svc := NewService(&mockUserRepo{})
|
||||
|
||||
_, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{Age: ptrInt(5)})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProfile_InvalidAge_TooHigh(t *testing.T) {
|
||||
svc := NewService(&mockUserRepo{})
|
||||
|
||||
_, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{Age: ptrInt(150)})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProfile_InvalidGender(t *testing.T) {
|
||||
svc := NewService(&mockUserRepo{})
|
||||
|
||||
_, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{Gender: ptrStr("other")})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProfile_InvalidActivity(t *testing.T) {
|
||||
svc := NewService(&mockUserRepo{})
|
||||
|
||||
_, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{Activity: ptrStr("extreme")})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProfile_InvalidGoal(t *testing.T) {
|
||||
svc := NewService(&mockUserRepo{})
|
||||
|
||||
_, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{Goal: ptrStr("bulk")})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProfile_Preferences(t *testing.T) {
|
||||
prefs := json.RawMessage(`{"cuisines":["russian"]}`)
|
||||
repo := &mockUserRepo{
|
||||
updateFn: func(ctx context.Context, id string, req UpdateProfileRequest) (*User, error) {
|
||||
return &User{
|
||||
ID: id,
|
||||
Plan: "free",
|
||||
Preferences: *req.Preferences,
|
||||
UpdatedAt: time.Now(),
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
svc := NewService(repo)
|
||||
|
||||
u, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{Preferences: &prefs})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if string(u.Preferences) != `{"cuisines":["russian"]}` {
|
||||
t.Errorf("unexpected preferences: %s", u.Preferences)
|
||||
}
|
||||
}
|
||||
49
backend/migrations/001_create_users.sql
Normal file
@@ -0,0 +1,49 @@
|
||||
-- +goose Up
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
CREATE TYPE user_plan AS ENUM ('free', 'paid');
|
||||
CREATE TYPE user_gender AS ENUM ('male', 'female');
|
||||
CREATE TYPE user_goal AS ENUM ('lose', 'maintain', 'gain');
|
||||
CREATE TYPE activity_level AS ENUM ('low', 'moderate', 'high');
|
||||
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
firebase_uid VARCHAR(128) NOT NULL UNIQUE,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
avatar_url TEXT,
|
||||
|
||||
-- Body parameters
|
||||
height_cm SMALLINT,
|
||||
weight_kg DECIMAL(5,2),
|
||||
age SMALLINT,
|
||||
gender user_gender,
|
||||
activity activity_level,
|
||||
|
||||
-- Goal and calculated daily norm
|
||||
goal user_goal,
|
||||
daily_calories INTEGER,
|
||||
|
||||
-- Plan
|
||||
plan user_plan NOT NULL DEFAULT 'free',
|
||||
|
||||
-- Preferences (JSONB for flexibility)
|
||||
preferences JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
-- Refresh token
|
||||
refresh_token TEXT,
|
||||
token_expires_at TIMESTAMPTZ,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_users_firebase_uid ON users (firebase_uid);
|
||||
CREATE INDEX idx_users_email ON users (email);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS users;
|
||||
DROP TYPE IF EXISTS activity_level;
|
||||
DROP TYPE IF EXISTS user_goal;
|
||||
DROP TYPE IF EXISTS user_gender;
|
||||
DROP TYPE IF EXISTS user_plan;
|
||||
45
client/.gitignore
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
36
client/.metadata
Normal file
@@ -0,0 +1,36 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "a402d9a4376add5bc2d6b1e33e53edaae58c07f8"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8
|
||||
base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8
|
||||
- platform: android
|
||||
create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8
|
||||
base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8
|
||||
- platform: ios
|
||||
create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8
|
||||
base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8
|
||||
- platform: web
|
||||
create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8
|
||||
base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
151
client/README.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# FoodAI Client
|
||||
|
||||
Flutter-приложение для управления питанием с поддержкой iOS, Android и Web.
|
||||
|
||||
## Стек
|
||||
|
||||
- **Flutter 3 / Dart 3** — фреймворк
|
||||
- **Riverpod** — управление состоянием
|
||||
- **go_router** — навигация с auth guard
|
||||
- **Dio** — HTTP-клиент с автоматическим обновлением токенов
|
||||
- **Firebase Auth** — аутентификация (email + Google)
|
||||
- **flutter_secure_storage** — безопасное хранение токенов
|
||||
- **json_serializable** — генерация сериализации моделей
|
||||
|
||||
## Требования
|
||||
|
||||
- Flutter SDK 3.x (`flutter --version`)
|
||||
- Dart SDK 3.9+
|
||||
- Android Studio или Xcode (для запуска на симуляторе/устройстве)
|
||||
- Chrome (для запуска в браузере)
|
||||
- Проект Firebase с включёнными Email и Google Sign-In
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### 1. Установить зависимости
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
### 2. Настроить Firebase
|
||||
|
||||
1. Создайте проект в [Firebase Console](https://console.firebase.google.com)
|
||||
2. Включите **Authentication → Sign-in method**: Email/Password и Google
|
||||
|
||||
#### Android
|
||||
|
||||
1. Project Settings (⚙️) → **Your apps** → иконка Android (`🤖`)
|
||||
2. Package name: `com.foodai.food_ai`
|
||||
3. Скачать `google-services.json` → положить в `android/app/`:
|
||||
```bash
|
||||
cp ~/Downloads/google-services.json android/app/google-services.json
|
||||
```
|
||||
|
||||
#### iOS
|
||||
|
||||
1. Project Settings (⚙️) → **Your apps** → иконка iOS (``)
|
||||
2. Bundle ID: `com.foodai.foodAi`
|
||||
3. Скачать `GoogleService-Info.plist` → добавить через Xcode в группу `Runner`:
|
||||
```bash
|
||||
open ios/Runner.xcworkspace
|
||||
# Перетащить GoogleService-Info.plist в группу Runner в навигаторе Xcode
|
||||
```
|
||||
|
||||
#### Web
|
||||
|
||||
1. Project Settings (⚙️) → **Your apps** → иконка Web (`</>`)
|
||||
2. Nickname: `FoodAI Web`
|
||||
3. Скопировать полученный `firebaseConfig` в `web/index.html`, заменив `YOUR_*` placeholder'ы
|
||||
4. Убедиться, что `localhost` есть в списке авторизованных доменов:
|
||||
Authentication → **Settings** → **Authorized domains**
|
||||
|
||||
> **Важно:** не коммитить конфиг-файлы в git:
|
||||
> ```bash
|
||||
> echo "android/app/google-services.json" >> .gitignore
|
||||
> echo "ios/Runner/GoogleService-Info.plist" >> .gitignore
|
||||
> ```
|
||||
|
||||
### 3. Настроить URL бэкенда
|
||||
|
||||
Откройте `lib/core/config/app_config.dart` и укажите адрес API:
|
||||
|
||||
```dart
|
||||
static const development = AppConfig(
|
||||
apiBaseUrl: 'http://10.0.2.2:9090', // Android-эмулятор → localhost:9090
|
||||
// apiBaseUrl: 'http://localhost:9090', // iOS-симулятор / Web
|
||||
);
|
||||
|
||||
static const production = AppConfig(
|
||||
apiBaseUrl: 'https://api.food-ai.app',
|
||||
);
|
||||
```
|
||||
|
||||
> Бэкенд по умолчанию поднимается на порту `9090` через Docker Compose (`9090:8080`).
|
||||
|
||||
### 4. Сгенерировать код
|
||||
|
||||
```bash
|
||||
flutter pub run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
### 5. Запустить приложение
|
||||
|
||||
```bash
|
||||
flutter run
|
||||
```
|
||||
|
||||
## Команды
|
||||
|
||||
| Команда | Описание |
|
||||
|---|---|
|
||||
| `flutter pub get` | Установить зависимости |
|
||||
| `flutter run` | Запустить на подключённом устройстве/симуляторе |
|
||||
| `flutter run -d chrome` | Запустить в браузере (web) |
|
||||
| `flutter test` | Запустить все тесты |
|
||||
| `flutter analyze` | Статический анализ кода |
|
||||
| `flutter build apk` | Собрать APK для Android |
|
||||
| `flutter build ios` | Собрать для iOS |
|
||||
| `flutter pub run build_runner build` | Сгенерировать код (json_serializable) |
|
||||
| `flutter pub run build_runner watch` | Следить за изменениями и генерировать |
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
client/
|
||||
├── lib/
|
||||
│ ├── core/
|
||||
│ │ ├── api/ # ApiClient (Dio), AuthInterceptor, исключения
|
||||
│ │ ├── auth/ # AuthService, AuthNotifier (Riverpod), SecureStorage
|
||||
│ │ ├── config/ # AppConfig (URL бэкенда по окружению)
|
||||
│ │ ├── router/ # GoRouter с auth guard и MainShell
|
||||
│ │ └── theme/ # Цвета и тема Material 3
|
||||
│ ├── features/
|
||||
│ │ ├── auth/ # LoginScreen, RegisterScreen
|
||||
│ │ ├── home/ # HomeScreen (placeholder)
|
||||
│ │ ├── menu/ # MenuScreen (placeholder)
|
||||
│ │ ├── products/ # ProductsScreen (placeholder)
|
||||
│ │ ├── profile/ # ProfileScreen (placeholder)
|
||||
│ │ └── recipes/ # RecipesScreen (placeholder)
|
||||
│ ├── shared/
|
||||
│ │ └── models/ # User (json_serializable + user.g.dart)
|
||||
│ ├── app.dart # Корневой виджет
|
||||
│ └── main.dart # Точка входа, инициализация Firebase
|
||||
└── test/
|
||||
├── features/auth/ # Тесты LoginScreen, RegisterScreen
|
||||
└── shared/models/ # Тесты модели User
|
||||
```
|
||||
|
||||
## Навигация
|
||||
|
||||
Приложение использует `go_router` с guard-ом авторизации:
|
||||
|
||||
- `/login` — экран входа (только для неавторизованных)
|
||||
- `/register` — экран регистрации
|
||||
- `/` → `/home` — главная (требует авторизации)
|
||||
- `/products` — продукты
|
||||
- `/menu` — меню дня
|
||||
- `/recipes` — рецепты
|
||||
- `/profile` — профиль пользователя
|
||||
|
||||
Неавторизованный пользователь автоматически перенаправляется на `/login`. После входа — на `/home`.
|
||||
28
client/analysis_options.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
# This file configures the analyzer, which statically analyzes Dart code to
|
||||
# check for errors, warnings, and lints.
|
||||
#
|
||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||
# invoked from the command line by running `flutter analyze`.
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
# included above or to enable additional rules. A list of all available lints
|
||||
# and their documentation is published at https://dart.dev/lints.
|
||||
#
|
||||
# Instead of disabling a lint rule for the entire project in the
|
||||
# section below, it can also be suppressed for a single line of code
|
||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||
# producing the lint.
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
14
client/android/.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
44
client/android/app/build.gradle.kts
Normal file
@@ -0,0 +1,44 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.foodai.food_ai"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.foodai.food_ai"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
7
client/android/app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
45
client/android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,45 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="food_ai"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.foodai.food_ai
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
BIN
client/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 544 B |
BIN
client/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 442 B |
BIN
client/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 721 B |
BIN
client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
18
client/android/app/src/main/res/values-night/styles.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
18
client/android/app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
7
client/android/app/src/profile/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
24
client/android/build.gradle.kts
Normal file
@@ -0,0 +1,24 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
3
client/android/gradle.properties
Normal file
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
5
client/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
|
||||
26
client/android/settings.gradle.kts
Normal file
@@ -0,0 +1,26 @@
|
||||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.9.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
34
client/ios/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
**/dgph
|
||||
*.mode1v3
|
||||
*.mode2v3
|
||||
*.moved-aside
|
||||
*.pbxuser
|
||||
*.perspectivev3
|
||||
**/*sync/
|
||||
.sconsign.dblite
|
||||
.tags*
|
||||
**/.vagrant/
|
||||
**/DerivedData/
|
||||
Icon?
|
||||
**/Pods/
|
||||
**/.symlinks/
|
||||
profile
|
||||
xcuserdata
|
||||
**/.generated/
|
||||
Flutter/App.framework
|
||||
Flutter/Flutter.framework
|
||||
Flutter/Flutter.podspec
|
||||
Flutter/Generated.xcconfig
|
||||
Flutter/ephemeral/
|
||||
Flutter/app.flx
|
||||
Flutter/app.zip
|
||||
Flutter/flutter_assets/
|
||||
Flutter/flutter_export_environment.sh
|
||||
ServiceDefinitions.json
|
||||
Runner/GeneratedPluginRegistrant.*
|
||||
|
||||
# Exceptions to above rules.
|
||||
!default.mode1v3
|
||||
!default.mode2v3
|
||||
!default.pbxuser
|
||||
!default.perspectivev3
|
||||
26
client/ios/Flutter/AppFrameworkInfo.plist
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>App</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>io.flutter.flutter.app</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>App</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>13.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
1
client/ios/Flutter/Debug.xcconfig
Normal file
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
1
client/ios/Flutter/Release.xcconfig
Normal file
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
616
client/ios/Runner.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,616 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
|
||||
remoteInfo = Runner;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */,
|
||||
);
|
||||
path = RunnerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */,
|
||||
);
|
||||
name = Flutter;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146E51CF9000F007C117D = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9740EEB11CF90186004384FC /* Flutter */,
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146EF1CF9000F007C117D /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146F01CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
||||
97C147021CF9000F007C117D /* Info.plist */,
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
);
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
331C8080294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
331C807D294A63A400263BE5 /* Sources */,
|
||||
331C807F294A63A400263BE5 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
331C8086294A63A400263BE5 /* PBXTargetDependency */,
|
||||
);
|
||||
name = RunnerTests;
|
||||
productName = RunnerTests;
|
||||
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = Runner;
|
||||
productName = Runner;
|
||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
97C146E61CF9000F007C117D /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1510;
|
||||
ORGANIZATIONNAME = "";
|
||||
TargetAttributes = {
|
||||
331C8080294A63A400263BE5 = {
|
||||
CreatedOnToolsVersion = 14.0;
|
||||
TestTargetID = 97C146ED1CF9000F007C117D;
|
||||
};
|
||||
97C146ED1CF9000F007C117D = {
|
||||
CreatedOnToolsVersion = 7.3.1;
|
||||
LastSwiftMigration = 1100;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
|
||||
compatibilityVersion = "Xcode 9.3";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
97C146ED1CF9000F007C117D /* Runner */,
|
||||
331C8080294A63A400263BE5 /* RunnerTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
331C807F294A63A400263BE5 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EC1CF9000F007C117D /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
|
||||
);
|
||||
name = "Thin Binary";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||
};
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Run Script";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
331C807D294A63A400263BE5 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EA1CF9000F007C117D /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 97C146ED1CF9000F007C117D /* Runner */;
|
||||
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
97C146FB1CF9000F007C117D /* Base */,
|
||||
);
|
||||
name = Main.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
97C147001CF9000F007C117D /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
249021D3217E4FDB00AE95B9 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
249021D4217E4FDB00AE95B9 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.foodai.foodAi;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
331C8088294A63A400263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.foodai.foodAi.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
331C8089294A63A400263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.foodai.foodAi.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
331C808A294A63A400263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.foodai.foodAi.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
97C147031CF9000F007C117D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
97C147041CF9000F007C117D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
97C147061CF9000F007C117D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.foodai.foodAi;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
97C147071CF9000F007C117D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.foodai.foodAi;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
331C8088294A63A400263BE5 /* Debug */,
|
||||
331C8089294A63A400263BE5 /* Release */,
|
||||
331C808A294A63A400263BE5 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
97C147031CF9000F007C117D /* Debug */,
|
||||
97C147041CF9000F007C117D /* Release */,
|
||||
249021D3217E4FDB00AE95B9 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
97C147061CF9000F007C117D /* Debug */,
|
||||
97C147071CF9000F007C117D /* Release */,
|
||||
249021D4217E4FDB00AE95B9 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||
}
|
||||
7
client/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1510"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "331C8080294A63A400263BE5"
|
||||
BuildableName = "RunnerTests.xctest"
|
||||
BlueprintName = "RunnerTests"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
enableGPUValidationMode = "1"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Profile"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
7
client/ios/Runner.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
13
client/ios/Runner/AppDelegate.swift
Normal file
@@ -0,0 +1,13 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "83.5x83.5",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "Icon-App-1024x1024@1x.png",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
23
client/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@3x.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
BIN
client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
5
client/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Launch Screen Assets
|
||||
|
||||
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
|
||||
|
||||
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
|
||||
37
client/ios/Runner/Base.lproj/LaunchScreen.storyboard
Normal file
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="168" height="185"/>
|
||||
</resources>
|
||||
</document>
|
||||
26
client/ios/Runner/Base.lproj/Main.storyboard
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Flutter View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
49
client/ios/Runner/Info.plist
Normal file
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Food Ai</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>food_ai</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
1
client/ios/Runner/Runner-Bridging-Header.h
Normal file
@@ -0,0 +1 @@
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||