Initial commit: Custom Start Page application with authentication and DynamoDB storage

This commit is contained in:
2026-02-18 22:06:43 -05:00
commit 7175ff14ba
47 changed files with 7592 additions and 0 deletions

18
.env.example Normal file
View File

@@ -0,0 +1,18 @@
# Server Configuration
PORT=8080
HOST=localhost
# Database Configuration (DynamoDB)
AWS_REGION=us-east-1
DYNAMODB_ENDPOINT=http://localhost:8000
TABLE_PREFIX=startpage_
USE_LOCAL_DB=true
# OAuth Configuration
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GOOGLE_REDIRECT_URL=http://localhost:8080/auth/callback/google
# Session Configuration
SESSION_SECRET=change-me-in-production
SESSION_MAX_AGE=604800

50
.gitignore vendored Normal file
View File

@@ -0,0 +1,50 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
bin/
*.test
*.out
# Go workspace file
go.work
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool
*.out
coverage.html
coverage.out
# Dependency directories
vendor/
# Go module cache
go.sum
# IDE specific files
.idea/
.vscode/
*.swp
*.swo
*~
# OS specific files
.DS_Store
Thumbs.db
# Environment variables
.env
# DynamoDB local data
dynamodb-local-data/
# Logs
*.log
# Temporary files
tmp/
temp/

View File

@@ -0,0 +1 @@
{"generationMode": "requirements-first"}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,205 @@
# Requirements Document: Custom Start Page Application
## Introduction
The Custom Start Page Application is a personalized dashboard web application that allows users to create and manage custom start pages with configurable widgets, multiple pages, and customizable search functionality. The application is modeled after start.me and provides users with a centralized hub for their frequently accessed links, notes, weather information, and other useful widgets.
## Glossary
- **User**: An authenticated individual who creates and manages their custom start pages
- **Dashboard**: The main application interface containing pages and widgets
- **Page**: A tab-based container that holds multiple widgets (similar to browser tabs)
- **Widget**: A modular component that displays specific content (bookmarks, notes, weather, etc.)
- **Bookmark_Widget**: A widget type that displays and organizes collections of links
- **Notes_Widget**: A widget type that allows users to create and edit text notes
- **Weather_Widget**: A widget type that displays weather information for specified locations
- **Search_Provider**: A configurable search engine (Google, DuckDuckGo, Bing, etc.)
- **OAuth_Provider**: An external authentication service (Google, GitHub, Microsoft, etc.)
- **Authentication_System**: The system component responsible for user login and session management
- **Widget_Manager**: The system component responsible for creating, updating, and deleting widgets
- **Page_Manager**: The system component responsible for managing pages and their ordering
- **Storage_Service**: The system component responsible for persisting user data
- **Tag**: A label that can be applied to bookmarks or notes for categorization and filtering
- **Group**: A named collection of bookmarks within a widget for organizational purposes
## Requirements
### Requirement 1: User Authentication
**User Story:** As a user, I want to log in using OAuth providers, so that I can securely access my personalized start page without creating another password.
#### Acceptance Criteria
1. WHEN a user visits the application without authentication, THE Authentication_System SHALL display a login page with OAuth provider options
2. WHEN a user selects an OAuth provider, THE Authentication_System SHALL redirect to the provider's authorization page
3. WHEN OAuth authorization succeeds, THE Authentication_System SHALL create or retrieve the user account and establish an authenticated session
4. WHEN OAuth authorization fails, THE Authentication_System SHALL display an error message and return to the login page
5. THE Authentication_System SHALL support Google OAuth as a provider
6. WHERE additional OAuth providers are configured, THE Authentication_System SHALL display them as login options
7. WHEN a user is authenticated, THE Authentication_System SHALL maintain the session across browser refreshes
8. WHEN a user logs out, THE Authentication_System SHALL terminate the session and redirect to the login page
### Requirement 2: Page Management
**User Story:** As a user, I want to create and manage multiple pages, so that I can organize my widgets into different categories or contexts.
#### Acceptance Criteria
1. WHEN a user first logs in, THE Page_Manager SHALL create a default page named "Home"
2. WHEN a user clicks the add page button, THE Page_Manager SHALL create a new page and add it to the page list
3. WHEN a user creates a new page, THE Page_Manager SHALL prompt for a page name
4. WHEN a user clicks on a page tab, THE Dashboard SHALL display that page's widgets
5. WHEN a user deletes a page, THE Page_Manager SHALL remove the page and all its widgets
6. IF a user attempts to delete the last remaining page, THEN THE Page_Manager SHALL prevent the deletion and display a message
7. WHEN a user reorders pages, THE Page_Manager SHALL update the page order and persist the change
8. THE Page_Manager SHALL persist all page changes to the Storage_Service immediately
### Requirement 3: Search Functionality
**User Story:** As a user, I want to search the web from my start page with a customizable search provider, so that I can quickly find information without navigating to a search engine first.
#### Acceptance Criteria
1. THE Dashboard SHALL display a search bar in the top toolbar
2. WHEN a user types a query and presses Enter, THE Dashboard SHALL open a new tab with search results from the configured Search_Provider
3. WHEN a user opens search settings, THE Dashboard SHALL display available Search_Provider options
4. WHEN a user selects a Search_Provider, THE Dashboard SHALL update the search configuration and persist the change
5. THE Dashboard SHALL support Google, DuckDuckGo, and Bing as Search_Provider options
6. WHEN no Search_Provider is configured, THE Dashboard SHALL default to Google
7. WHEN a user submits an empty search query, THE Dashboard SHALL not perform a search
### Requirement 4: Bookmark Widget
**User Story:** As a user, I want to create bookmark widgets to organize my frequently visited links with tags and groups, so that I can manage thousands of bookmarks efficiently and quickly access websites from my start page.
#### Acceptance Criteria
1. WHEN a user adds a Bookmark_Widget, THE Widget_Manager SHALL create the widget and add it to the current page
2. WHEN a user adds a bookmark to a Bookmark_Widget, THE Widget_Manager SHALL store the bookmark with its title, URL, and optional tags
3. WHEN a user clicks a bookmark, THE Dashboard SHALL open the URL in a new tab
4. WHEN a user edits a bookmark, THE Widget_Manager SHALL update the bookmark's title, URL, or tags
5. WHEN a user deletes a bookmark, THE Widget_Manager SHALL remove it from the Bookmark_Widget
6. WHEN a user reorders bookmarks within a Bookmark_Widget, THE Widget_Manager SHALL update the bookmark order
7. THE Bookmark_Widget SHALL display bookmarks as a list with titles and optional icons
8. WHEN a user sets a custom title for a Bookmark_Widget, THE Widget_Manager SHALL update and display the custom title
9. WHEN a user adds tags to a bookmark, THE Storage_Service SHALL support one-to-many tag relationships (multiple tags per bookmark)
10. WHEN a user filters bookmarks by tag, THE Bookmark_Widget SHALL display only bookmarks matching the selected tag(s)
11. WHEN a user creates a group within a Bookmark_Widget, THE Widget_Manager SHALL allow organizing bookmarks into named groups
12. WHEN a user moves a bookmark between groups, THE Widget_Manager SHALL update the bookmark's group assignment
13. THE Storage_Service SHALL efficiently support storing and retrieving 10,000+ bookmarks per user
14. WHEN a user quickly changes tags on multiple bookmarks, THE Storage_Service SHALL handle rapid updates without data loss
15. WHEN a user deletes a tag, THE Widget_Manager SHALL remove the tag from all associated bookmarks
16. WHEN a user shares a bookmark, THE Dashboard SHALL generate a shareable link or export option
### Requirement 5: Notes Widget
**User Story:** As a user, I want to create notes widgets to store rich text information with tags and multiple content formats, so that I can keep organized reminders, code snippets, and formatted notes on my start page and easily find them later.
#### Acceptance Criteria
1. WHEN a user adds a Notes_Widget, THE Widget_Manager SHALL create the widget and add it to the current page
2. WHEN a user types in a Notes_Widget, THE Widget_Manager SHALL save the content
3. THE Notes_Widget SHALL support multiple content format modes: plain text, rich text (RTF), code blocks, YAML, and Markdown
4. WHEN a user selects a content format mode, THE Notes_Widget SHALL render the content appropriately for that format
5. THE Notes_Widget SHALL support full Unicode character sets for international text and emoji
6. WHEN a user enters code blocks, THE Notes_Widget SHALL provide syntax highlighting for common programming languages
7. WHEN a user enters YAML content, THE Notes_Widget SHALL preserve indentation and structure
8. WHEN a user uses rich text mode, THE Notes_Widget SHALL support basic formatting (bold, italic, lists, headings)
9. WHEN a user edits note content, THE Widget_Manager SHALL persist changes to the Storage_Service
10. WHEN a user sets a custom title for a Notes_Widget, THE Widget_Manager SHALL update and display the custom title
11. THE Notes_Widget SHALL preserve line breaks, indentation, and formatting in all content modes
12. WHEN a user adds tags to a note, THE Storage_Service SHALL support one-to-many tag relationships (multiple tags per note)
13. WHEN a user filters notes by tag, THE Notes_Widget SHALL display only notes matching the selected tag(s)
14. THE Storage_Service SHALL efficiently support storing and retrieving thousands of notes per user with various content formats
15. WHEN a user quickly changes tags on multiple notes, THE Storage_Service SHALL handle rapid updates without data loss
16. WHEN a user deletes a tag, THE Widget_Manager SHALL remove the tag from all associated notes
17. WHEN a user shares a note, THE Dashboard SHALL generate a shareable link or export option that preserves formatting
18. WHEN a user switches between content format modes, THE Notes_Widget SHALL preserve the raw content without data loss
### Requirement 6: Weather Widget
**User Story:** As a user, I want to add weather widgets to see current weather conditions, so that I can quickly check the weather without visiting a weather website.
#### Acceptance Criteria
1. WHEN a user adds a Weather_Widget, THE Widget_Manager SHALL create the widget and prompt for a location
2. WHEN a user specifies a location, THE Weather_Widget SHALL fetch and display current weather conditions
3. THE Weather_Widget SHALL display temperature, weather condition description, and an appropriate weather icon
4. WHEN a user changes the location, THE Weather_Widget SHALL update to show weather for the new location
5. WHEN weather data cannot be retrieved, THE Weather_Widget SHALL display an error message
6. THE Weather_Widget SHALL refresh weather data periodically
7. WHEN a user sets a custom title for a Weather_Widget, THE Widget_Manager SHALL update and display the custom title
### Requirement 7: Widget Management
**User Story:** As a user, I want to add, remove, resize, and reposition widgets on my pages, so that I can customize my start page layout to my preferences.
#### Acceptance Criteria
1. WHEN a user clicks an add widget button, THE Dashboard SHALL display a widget type selection menu
2. WHEN a user selects a widget type, THE Widget_Manager SHALL create the widget and add it to the current page
3. WHEN a user deletes a widget, THE Widget_Manager SHALL remove it from the page
4. WHEN a user drags a widget, THE Dashboard SHALL allow repositioning within the page
5. WHEN a user drops a widget in a new position, THE Widget_Manager SHALL update the widget position and persist the change
6. WHERE the application supports widget resizing, THE Dashboard SHALL allow users to resize widgets
7. THE Widget_Manager SHALL persist all widget changes to the Storage_Service immediately
8. WHEN a widget is created, THE Widget_Manager SHALL assign it a unique identifier
### Requirement 8: Data Persistence
**User Story:** As a user, I want my pages, widgets, and settings to be saved automatically, so that my customizations are preserved across sessions.
#### Acceptance Criteria
1. WHEN a user makes any change to pages, widgets, or settings, THE Storage_Service SHALL persist the change immediately
2. WHEN a user logs in, THE Storage_Service SHALL retrieve all user data and restore the previous state
3. THE Storage_Service SHALL store page configurations including page names and order
4. THE Storage_Service SHALL store widget configurations including type, position, size, and content
5. THE Storage_Service SHALL store user preferences including selected Search_Provider
6. WHEN storage operations fail, THE Dashboard SHALL display an error message to the user
7. THE Storage_Service SHALL associate all data with the authenticated user account
8. THE Storage_Service SHALL be designed to scale efficiently for users with 10,000+ bookmarks and notes
9. THE Storage_Service SHALL support efficient querying by tags for both bookmarks and notes
10. THE Storage_Service SHALL handle concurrent updates to tags and content without data corruption
### Requirement 9: Responsive Design
**User Story:** As a user, I want the application to work well on different screen sizes, so that I can access my start page from various devices.
#### Acceptance Criteria
1. WHEN the application is viewed on a desktop screen, THE Dashboard SHALL display widgets in a multi-column grid layout
2. WHEN the application is viewed on a tablet screen, THE Dashboard SHALL adjust the layout to fit the available width
3. WHEN the application is viewed on a mobile screen, THE Dashboard SHALL display widgets in a single-column layout
4. THE Dashboard SHALL ensure all interactive elements are appropriately sized for touch input on mobile devices
5. WHEN the screen size changes, THE Dashboard SHALL reflow the layout without requiring a page refresh
### Requirement 10: User Interface and Navigation
**User Story:** As a user, I want an intuitive interface with clear navigation, so that I can easily manage my pages and widgets.
#### Acceptance Criteria
1. THE Dashboard SHALL display a top toolbar containing the search bar, page tabs, and user menu
2. THE Dashboard SHALL display page tabs horizontally in the top toolbar
3. WHEN a user clicks the user menu, THE Dashboard SHALL display options for settings and logout
4. THE Dashboard SHALL provide visual feedback for interactive elements on hover and click
5. WHEN a user performs an action, THE Dashboard SHALL provide immediate visual confirmation
6. THE Dashboard SHALL use consistent styling and spacing throughout the interface
7. WHEN loading data, THE Dashboard SHALL display loading indicators to inform the user
### Requirement 11: Database Technology Evaluation
**User Story:** As a system architect, I want to evaluate DynamoDB as the storage solution, so that I can ensure the application scales efficiently for users with thousands of bookmarks and notes.
#### Acceptance Criteria
1. THE design document SHALL evaluate DynamoDB's suitability for storing user data, pages, widgets, bookmarks, and notes
2. THE design document SHALL analyze DynamoDB's ability to handle 10,000+ bookmarks per user with efficient retrieval
3. THE design document SHALL evaluate access patterns for tag-based queries and filtering
4. THE design document SHALL assess DynamoDB's performance for rapid tag updates and deletions across multiple items
5. THE design document SHALL consider alternative database solutions if DynamoDB limitations are identified
6. THE design document SHALL define the data model and indexing strategy for tags (one-to-many relationships)
7. THE design document SHALL address how to efficiently query bookmarks/notes by multiple tags
8. THE design document SHALL evaluate the cost implications of the chosen storage solution at scale
9. THE design document SHALL consider data consistency requirements for concurrent updates

View File

@@ -0,0 +1,634 @@
# Implementation Plan: Custom Start Page Application
## Overview
This implementation plan breaks down the Custom Start Page Application into discrete, actionable coding tasks. The application uses a hypermedia-driven architecture with HTMX for dynamic interactions, Go for the backend, and DynamoDB for data storage. The implementation follows an incremental approach, building core infrastructure first, then adding features layer by layer, with testing integrated throughout.
The plan is organized into major phases:
1. Project setup and infrastructure
2. Authentication system
3. Core data models and DynamoDB integration
4. Page and widget management
5. Bookmark widget with tags and groups
6. Notes widget with rich content support
7. Weather widget
8. Sharing functionality
9. UI polish and responsive design
10. Performance optimization and scale testing
Each task references specific requirements from the requirements document and includes sub-tasks for implementation and testing.
## Tasks
- [x] 1. Project setup and infrastructure
- Initialize Go project with module structure
- Set up directory structure (cmd, internal, pkg, templates, static)
- Configure DynamoDB local for development
- Set up testing framework (testing package + gopter for property tests)
- Create base HTML templates with HTMX integration
- Set up Tailwind CSS or basic CSS framework
- Configure environment variables and configuration management
- _Requirements: All (foundational)_
- [ ] 2. Implement authentication system
- [x] 2.1 Create User data model and DynamoDB Users table
- Define User struct with all fields (id, email, oauth_provider, oauth_id, timestamps)
- Implement DynamoDB table creation script
- Implement user CRUD operations in storage layer
- _Requirements: 1.1, 1.3_
- [x] 2.2 Implement OAuth service with Google provider
- Set up golang.org/x/oauth2 configuration
- Implement InitiateOAuth method (generate redirect URL)
- Implement HandleOAuthCallback method (exchange code for token, create/retrieve user)
- Create OAuth endpoints (GET /auth/oauth/:provider, GET /auth/callback/:provider)
- _Requirements: 1.2, 1.3, 1.5, 1.6_
- [x] 2.3 Implement session management
- Set up gorilla/sessions or similar for session handling
- Implement ValidateSession method
- Implement Logout method
- Create session middleware for protected routes
- Store session tokens in secure, httponly cookies
- _Requirements: 1.7, 1.8_
- [x] 2.4 Create login and dashboard templates
- Create login.html template with OAuth provider buttons
- Create base dashboard layout template
- Implement redirect logic (unauthenticated → login, authenticated → dashboard)
- _Requirements: 1.1_
- [ ]* 2.5 Write property tests for authentication
- **Property 1: Unauthenticated users see login page**
- **Property 3: Successful OAuth creates or retrieves user**
- **Property 5: Session persistence across refresh**
- **Property 6: Logout terminates session**
- **Validates: Requirements 1.1, 1.3, 1.7, 1.8**
- [ ]* 2.6 Write unit tests for authentication edge cases
- Test OAuth failure scenarios (invalid code, network errors)
- Test session expiration handling
- Test concurrent session conflicts
- _Requirements: 1.4_
- [ ] 3. Implement core data models and DynamoDB integration
- [x] 3.1 Create DynamoDB storage service wrapper
- Implement StorageService interface with DynamoDB operations
- Set up aws-sdk-go-v2 client with connection pooling
- Implement retry logic with exponential backoff
- Implement transaction support (TransactWrite)
- Implement batch operations (BatchGet, BatchWrite)
- _Requirements: 8.1, 8.8_
- [~] 3.2 Define all data models
- Create Page model struct
- Create Widget model struct with type enum
- Create Bookmark model struct with version field
- Create Note model struct with format and language fields
- Create TagAssociation model struct
- Create Group model struct
- Create Share model struct
- Create Preferences model struct
- _Requirements: 2.1, 4.2, 5.2, 8.3, 8.4, 8.5_
- [~] 3.3 Create DynamoDB table schemas
- Implement Pages table creation with composite key
- Implement Widgets table creation
- Implement Bookmarks table creation with UserBookmarksIndex GSI
- Implement Notes table creation with UserNotesIndex GSI
- Implement TagAssociations table creation with TagItemsIndex GSI
- Implement Groups table creation
- Implement SharedItems table creation with UserSharesIndex GSI
- Implement Preferences table creation
- _Requirements: 8.3, 8.4, 8.5, 11.6_
- [ ]* 3.4 Write property tests for data model round-trip
- **Property 44: Data round-trip consistency**
- Test serialization/deserialization for all models
- Test DynamoDB storage and retrieval preserves all fields
- **Validates: Requirements 8.2, 8.3, 8.4, 8.5**
- [ ] 4. Implement page management
- [~] 4.1 Create PageService with all CRUD operations
- Implement GetPages method (query by user_id, sort by order)
- Implement CreatePage method with default "Home" page for new users
- Implement UpdatePage method (name and order updates)
- Implement DeletePage method with cascade delete of widgets
- Implement ReorderPages method
- Add validation: prevent deletion of last page
- _Requirements: 2.1, 2.2, 2.3, 2.5, 2.6, 2.7, 2.8_
- [~] 4.2 Create page HTTP endpoints
- Implement GET /dashboard (full page with page tabs)
- Implement GET /pages/:id (returns widget grid HTML fragment)
- Implement POST /pages (create page, returns updated tabs HTML)
- Implement PUT /pages/:id (update page, returns updated tab HTML)
- Implement DELETE /pages/:id (delete page, returns updated tabs HTML)
- Implement POST /pages/reorder (reorder pages, returns updated tabs HTML)
- _Requirements: 2.2, 2.3, 2.4, 2.5, 2.7_
- [~] 4.3 Create page templates
- Create page tabs partial template
- Create page creation form template
- Create page edit form template
- Add HTMX attributes for dynamic updates
- _Requirements: 2.2, 2.3, 2.4, 10.2_
- [ ]* 4.4 Write property tests for page management
- **Property 7: New users get default page**
- **Property 8: Adding page increases count**
- **Property 9: Page switching displays correct widgets**
- **Property 10: Page deletion removes page and widgets**
- **Property 11: Page reordering persists**
- **Validates: Requirements 2.1, 2.2, 2.4, 2.5, 2.7**
- [ ]* 4.5 Write unit tests for page edge cases
- Test last page deletion prevention
- Test page name validation (length, special characters)
- Test concurrent page operations
- _Requirements: 2.6_
- [~] 5. Checkpoint - Ensure all tests pass
- Ensure all tests pass, ask the user if questions arise.
- [ ] 6. Implement widget management foundation
- [~] 6.1 Create WidgetService with CRUD operations
- Implement GetWidgets method (query by page_id)
- Implement CreateWidget method with unique ID generation
- Implement UpdateWidget method (position, size, config, title)
- Implement DeleteWidget method with cascade delete of widget data
- _Requirements: 7.1, 7.2, 7.3, 7.5, 7.7, 7.8_
- [~] 6.2 Create widget HTTP endpoints
- Implement GET /widgets/:id (returns widget HTML)
- Implement POST /pages/:pageId/widgets (create widget, returns widget HTML)
- Implement PUT /widgets/:id (update widget, returns updated HTML)
- Implement DELETE /widgets/:id (delete widget, returns empty)
- Implement POST /widgets/:id/position (update position, returns success)
- _Requirements: 7.2, 7.3, 7.4, 7.5_
- [~] 6.3 Create base widget templates
- Create widget container template with drag handles
- Create widget type selection menu template
- Create widget header template with title and delete button
- Add Sortable.js initialization for drag-and-drop
- _Requirements: 7.1, 7.4, 7.5_
- [ ]* 6.4 Write property tests for widget management
- **Property 15: Widget creation increases count**
- **Property 16: Widget deletion decreases count**
- **Property 17: Widget position updates persist**
- **Property 18: Widget IDs are unique**
- **Property 19: Widget title customization**
- **Validates: Requirements 7.2, 7.3, 7.5, 7.8, 4.8, 5.10, 6.7**
- [ ] 7. Implement tag system
- [~] 7.1 Create TagService with all operations
- Implement GetTags method (query user's tags with counts)
- Implement AddTagToItem method with transaction (increment version, create association)
- Implement RemoveTagFromItem method
- Implement DeleteTag method (batch delete all associations)
- Implement RenameTag method (batch update all associations)
- Implement GetTagSuggestions method (prefix search with autocomplete)
- Add tag normalization (lowercase, trim)
- _Requirements: 4.9, 4.14, 4.15, 5.12, 5.15, 5.16, 8.9, 8.10_
- [~] 7.2 Create tag HTTP endpoints
- Implement GET /tags (returns tag list HTML)
- Implement POST /items/:itemId/tags (add tag, returns updated tag list HTML)
- Implement DELETE /items/:itemId/tags/:tagName (remove tag, returns updated HTML)
- Implement DELETE /tags/:tagName (delete tag from all items, returns success)
- Implement PUT /tags/:oldName/rename (rename tag, returns success)
- Implement GET /tags/suggest?prefix=:prefix (returns datalist HTML)
- _Requirements: 4.9, 4.15, 5.12, 5.16_
- [~] 7.3 Create tag UI components
- Create tag input component with autocomplete
- Create tag list display component
- Create tag filter component (multi-select with AND/OR logic)
- Add HTMX attributes for dynamic tag operations
- _Requirements: 4.9, 4.10, 5.12, 5.13_
- [ ]* 7.4 Write property tests for tag system
- **Property 34: Tag association supports one-to-many**
- **Property 35: Tag filtering returns matching items**
- **Property 36: Tag deletion removes from all items**
- **Property 37: Concurrent tag updates preserve data**
- **Validates: Requirements 4.9, 4.10, 4.14, 4.15, 5.12, 5.13, 5.15, 5.16, 8.9, 8.10**
- [ ]* 7.5 Write unit tests for tag edge cases
- Test tag normalization (uppercase, whitespace)
- Test duplicate tag addition (idempotent)
- Test tag name conflicts during rename
- Test empty tag filter behavior
- Test non-existent tag in filter
- _Requirements: 4.9, 4.10, 4.15, 5.12, 5.13, 5.16_
- [ ] 8. Implement bookmark widget
- [~] 8.1 Create BookmarkService with all operations
- Implement GetBookmarks method (query by widget_id, sort by order)
- Implement CreateBookmark method with validation (title, URL required)
- Implement UpdateBookmark method with optimistic locking (version check)
- Implement DeleteBookmark method with cascade delete of tag associations
- Implement ReorderBookmarks method
- Implement GetBookmarksByTags method (query TagItemsIndex, compute intersection for AND logic)
- Add URL validation
- _Requirements: 4.2, 4.3, 4.4, 4.5, 4.6, 4.10, 4.13, 4.14_
- [~] 8.2 Create bookmark HTTP endpoints
- Implement GET /widgets/:widgetId/bookmarks (returns bookmarks HTML)
- Implement POST /widgets/:widgetId/bookmarks (create bookmark, returns bookmark HTML)
- Implement PUT /bookmarks/:id (update bookmark, returns updated HTML)
- Implement DELETE /bookmarks/:id (delete bookmark, returns empty)
- Implement POST /bookmarks/reorder (reorder bookmarks, returns success)
- Implement GET /bookmarks/filter?tags=tag1,tag2&logic=and (returns filtered HTML)
- _Requirements: 4.2, 4.3, 4.4, 4.5, 4.6, 4.10_
- [~] 8.3 Create bookmark widget templates
- Create bookmark widget container template
- Create bookmark list template with titles and URLs
- Create bookmark item template with click handler (open in new tab)
- Create bookmark add form template
- Create bookmark edit form template with tag input
- Add favicon display (optional)
- _Requirements: 4.1, 4.2, 4.3, 4.7, 4.8_
- [ ]* 8.4 Write property tests for bookmark widget
- **Property 20: Bookmark addition persists**
- **Property 21: Bookmark editing updates values**
- **Property 22: Bookmark deletion removes from list**
- **Property 23: Bookmark reordering persists**
- **Property 24: Bookmark rendering includes all titles**
- **Validates: Requirements 4.2, 4.4, 4.5, 4.6, 4.7**
- [ ]* 8.5 Write unit tests for bookmark edge cases
- Test invalid URL rejection
- Test empty title handling
- Test bookmark with 10+ tags
- Test version conflict handling
- _Requirements: 4.2, 4.4, 4.14_
- [ ] 9. Implement group system for bookmarks
- [~] 9.1 Create GroupService with all operations
- Implement GetGroups method (query by widget_id, sort by order)
- Implement CreateGroup method
- Implement UpdateGroup method (name and order updates)
- Implement DeleteGroup method (move bookmarks to specified destination)
- Implement MoveBookmarkToGroup method
- Implement ReorderGroups method
- _Requirements: 4.11, 4.12_
- [~] 9.2 Create group HTTP endpoints
- Implement GET /widgets/:widgetId/groups (returns groups HTML)
- Implement POST /widgets/:widgetId/groups (create group, returns group HTML)
- Implement PUT /groups/:id (update group, returns updated HTML)
- Implement DELETE /groups/:id (delete group, returns success)
- Implement POST /bookmarks/:id/move (move bookmark to group, returns updated HTML)
- _Requirements: 4.11, 4.12_
- [~] 9.3 Create group UI components
- Create group container template
- Create group header with name and collapse/expand
- Update bookmark list template to support grouping
- Create group creation form
- Add drag-and-drop between groups
- _Requirements: 4.11, 4.12_
- [ ]* 9.4 Write property tests for group system
- **Property 25: Group creation and assignment**
- **Property 26: Bookmark group reassignment**
- **Validates: Requirements 4.11, 4.12**
- [ ]* 9.5 Write unit tests for group edge cases
- Test deleting group with bookmarks (move to ungrouped)
- Test deleting group with bookmarks (move to another group)
- Test moving bookmark to non-existent group
- Test duplicate group names within widget
- _Requirements: 4.11, 4.12_
- [~] 10. Checkpoint - Ensure all tests pass
- Ensure all tests pass, ask the user if questions arise.
- [ ] 11. Implement notes widget with rich content support
- [~] 11.1 Create NotesService with all operations
- Implement GetNote method (query by widget_id)
- Implement UpdateNote method with optimistic locking (version check)
- Implement GetNotesByTags method (query TagItemsIndex)
- Add content size validation (warn at 50KB, reject at 400KB)
- Add format validation (enum: plain, rtf, code, yaml, markdown)
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.9, 5.13, 5.14, 5.15_
- [~] 11.2 Create notes HTTP endpoints
- Implement GET /widgets/:widgetId/note (returns note HTML)
- Implement POST /widgets/:widgetId/note (update note, returns save status HTML)
- Implement GET /notes/filter?tags=tag1,tag2&logic=and (returns filtered HTML)
- Add debounced auto-save (500ms delay)
- _Requirements: 5.2, 5.9, 5.13_
- [~] 11.3 Create notes widget templates for plain text mode
- Create notes widget container template
- Create plain text editor template (textarea with auto-save)
- Create format selector dropdown
- Add Unicode support (UTF-8 throughout)
- _Requirements: 5.1, 5.2, 5.3, 5.5, 5.10_
- [~] 11.4 Add rich text (RTF) mode support
- Integrate TinyMCE or Quill.js for rich text editing
- Implement RTF rendering with HTML sanitization (DOMPurify)
- Support basic formatting (bold, italic, lists, headings)
- Store content as sanitized HTML
- _Requirements: 5.3, 5.4, 5.8_
- [~] 11.5 Add code mode support with syntax highlighting
- Integrate CodeMirror or Monaco Editor for code editing
- Add language selector dropdown (JavaScript, Python, Go, Java, etc.)
- Integrate Prism.js or Highlight.js for syntax highlighting
- Add line numbers and copy button
- Store language metadata in Note model
- _Requirements: 5.3, 5.4, 5.6_
- [~] 11.6 Add YAML mode support
- Create YAML editor template (textarea with monospace font)
- Add tab support for indentation
- Render YAML in <pre> tag with indentation preservation
- Store content as-is (no validation)
- _Requirements: 5.3, 5.4, 5.7_
- [~] 11.7 Add Markdown mode support
- Create Markdown editor template (split view or live preview)
- Integrate marked.js for Markdown parsing
- Render Markdown as sanitized HTML
- Store content as raw Markdown
- _Requirements: 5.3, 5.4_
- [ ]* 11.8 Write property tests for notes widget
- **Property 27: Notes content persists**
- **Property 28: Content format preservation**
- **Property 29: Unicode content preservation**
- **Property 30: Format switching preserves content**
- **Property 31: Code syntax highlighting**
- **Property 32: YAML indentation preservation**
- **Property 33: RTF formatting preservation**
- **Validates: Requirements 5.2, 5.4, 5.5, 5.6, 5.7, 5.8, 5.9, 5.11, 5.18**
- [ ]* 11.9 Write unit tests for notes edge cases
- Test content size limits (50KB warning, 400KB rejection)
- Test format switching without data loss
- Test Unicode characters (emoji, international text)
- Test malformed YAML (stored as-is)
- Test malformed Markdown (rendered gracefully)
- Test XSS prevention in RTF mode
- Test version conflict handling
- _Requirements: 5.3, 5.4, 5.5, 5.14, 5.15, 5.18_
- [ ] 12. Implement weather widget
- [~] 12.1 Create WeatherService
- Implement GetWeather method (fetch from external API)
- Implement ValidateLocation method
- Add caching for weather data (TTL-based)
- Add error handling for API failures
- Choose weather API (OpenWeatherMap or similar)
- _Requirements: 6.2, 6.4, 6.5_
- [~] 12.2 Create weather HTTP endpoints
- Implement GET /weather?location=:location (returns weather HTML)
- Add periodic refresh logic (client-side polling or server-sent events)
- _Requirements: 6.2, 6.6_
- [~] 12.3 Create weather widget templates
- Create weather widget container template
- Create weather display template (temperature, condition, icon)
- Create location input form
- Add error message template for API failures
- _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.7_
- [ ]* 12.4 Write property tests for weather widget
- **Property 40: Weather data fetching**
- **Property 41: Weather location updates**
- **Property 42: Weather error handling**
- **Validates: Requirements 6.2, 6.3, 6.4, 6.5**
- [ ]* 12.5 Write unit tests for weather edge cases
- Test invalid location handling
- Test API timeout handling
- Test rate limiting
- Test cached data retrieval
- _Requirements: 6.4, 6.5_
- [ ] 13. Implement search functionality
- [~] 13.1 Create search provider configuration
- Define search provider enum (google, duckduckgo, bing)
- Implement search URL generation for each provider
- Store search provider preference in Preferences table
- Default to Google if not configured
- _Requirements: 3.2, 3.4, 3.5, 3.6_
- [~] 13.2 Create search UI components
- Create search bar template in top toolbar
- Add search form with HTMX attributes
- Add search provider selector in preferences
- Add empty query validation (client-side and server-side)
- _Requirements: 3.1, 3.2, 3.3, 3.7_
- [ ]* 13.3 Write property tests for search functionality
- **Property 12: Search generates correct URL**
- **Property 13: Search provider selection persists**
- **Property 14: Empty search is rejected**
- **Validates: Requirements 3.2, 3.4, 3.7**
- [ ] 14. Implement sharing functionality
- [~] 14.1 Create SharingService with all operations
- Implement CreateShare method (generate unique share ID)
- Implement GetSharedItem method (increment access count atomically)
- Implement RevokeShare method
- Implement GetUserShares method
- Add share ID generation (cryptographically random)
- Add expiration handling
- _Requirements: 4.16, 5.17_
- [~] 14.2 Create sharing HTTP endpoints
- Implement POST /share (create share, returns share dialog HTML with link)
- Implement GET /share/:shareId (public endpoint, returns shared item HTML)
- Implement DELETE /share/:shareId (revoke share, returns success)
- Implement GET /shares (returns user's shares list HTML)
- Add rate limiting on share access
- _Requirements: 4.16, 5.17_
- [~] 14.3 Create sharing UI components
- Create share button for bookmarks and notes
- Create share dialog template with copy link button
- Create shared item view template (read-only)
- Create shares list template
- Add expiration date selector (optional)
- _Requirements: 4.16, 5.17_
- [ ]* 14.4 Write property tests for sharing
- **Property 38: Share link generation**
- **Property 39: Shared note preserves formatting**
- **Validates: Requirements 4.16, 5.17**
- [ ]* 14.5 Write unit tests for sharing edge cases
- Test expired share access
- Test revoked share access
- Test share ID uniqueness
- Test access count increment
- Test XSS prevention in shared content
- _Requirements: 4.16, 5.17_
- [~] 15. Checkpoint - Ensure all tests pass
- Ensure all tests pass, ask the user if questions arise.
- [ ] 16. Implement preferences management
- [~] 16.1 Create preferences HTTP endpoints
- Implement GET /preferences (returns preferences form HTML)
- Implement POST /preferences (update preferences, returns success)
- _Requirements: 3.4, 8.5_
- [~] 16.2 Create preferences UI
- Create preferences page template
- Add search provider selector
- Add theme selector (optional)
- _Requirements: 3.3, 3.4_
- [ ]* 16.3 Write property tests for preferences
- Test preference persistence across sessions
- Test default values for new users
- **Validates: Requirements 3.4, 8.5**
- [ ] 17. Implement responsive design
- [~] 17.1 Create responsive layouts
- Implement multi-column grid layout for desktop
- Implement adaptive layout for tablet
- Implement single-column layout for mobile
- Add CSS media queries for breakpoints
- _Requirements: 9.1, 9.2, 9.3_
- [~] 17.2 Optimize for touch devices
- Ensure interactive elements are appropriately sized for touch
- Add touch-friendly drag-and-drop
- Test on mobile devices
- _Requirements: 9.4_
- [ ]* 17.3 Write property tests for responsive design
- **Property 50: Responsive layout reflow**
- Test layout at various viewport sizes
- **Validates: Requirements 9.5**
- [ ] 18. Implement UI polish and visual feedback
- [~] 18.1 Add visual feedback for interactions
- Add hover states for all interactive elements
- Add click/active states for buttons
- Add focus states for inputs
- Add loading indicators for async operations
- Add success/error toast notifications
- _Requirements: 10.4, 10.5, 10.7_
- [~] 18.2 Implement consistent styling
- Apply consistent spacing and typography
- Add consistent color scheme
- Add consistent border radius and shadows
- Ensure WCAG accessibility compliance
- _Requirements: 10.6_
- [~] 18.3 Create top toolbar
- Implement toolbar layout with search bar, page tabs, and user menu
- Add user menu dropdown (settings, logout)
- Style page tabs horizontally
- _Requirements: 10.1, 10.2, 10.3_
- [ ]* 18.4 Write property tests for UI feedback
- **Property 47: Interactive element feedback**
- **Property 48: Action confirmation feedback**
- **Property 49: Loading indicators**
- **Validates: Requirements 10.4, 10.5, 10.7**
- [ ] 19. Implement data persistence guarantees
- [ ]* 19.1 Write property tests for data persistence
- **Property 43: Immediate persistence**
- **Property 44: Data round-trip consistency**
- **Property 45: Data isolation**
- **Property 46: Storage error handling**
- Test all CRUD operations persist immediately
- Test logout/login restores state
- Test user data isolation
- **Validates: Requirements 2.8, 7.7, 8.1, 8.2, 8.6, 8.7**
- [ ]* 19.2 Write unit tests for error handling
- Test DynamoDB connection failures
- Test conditional write failures (version conflicts)
- Test transaction rollback
- Test batch operation partial failures
- _Requirements: 8.6, 8.10_
- [ ] 20. Performance optimization and scale testing
- [~] 20.1 Implement caching strategy
- Add Redis cache for frequently accessed data (user preferences, tag lists)
- Implement cache invalidation on updates
- Add ETags for static assets
- _Requirements: 4.13, 5.14, 8.8_
- [~] 20.2 Optimize DynamoDB queries
- Implement pagination for large bookmark/note lists
- Optimize batch operations
- Add connection pooling
- _Requirements: 4.13, 5.14, 8.8_
- [~] 20.3 Add performance monitoring
- Add CloudWatch metrics for DynamoDB operations
- Add latency tracking for all endpoints
- Add error rate monitoring
- _Requirements: 8.8_
- [ ]* 20.4 Write scale tests
- Test with 10,000+ bookmarks per user
- Test with 1,000+ notes per user
- Test with 100+ tags per user
- Test concurrent tag updates from multiple sessions
- Test tag deletion from 1,000+ items
- Measure query latency (target: <100ms p95)
- **Validates: Requirements 4.13, 4.14, 5.14, 5.15, 8.8, 11.2, 11.4**
- [ ]* 20.5 Write load tests
- Test 1,000 concurrent users
- Test 100 requests/second per server instance
- Measure p95 latency (target: <200ms)
- _Requirements: 8.8_
- [ ] 21. Final integration and end-to-end testing
- [ ]* 21.1 Write end-to-end tests for critical user journeys
- Test: Login → create page → add widgets → add bookmarks with tags
- Test: Create notes with different formats
- Test: Filter bookmarks/notes by tags
- Test: Create groups and organize bookmarks
- Test: Share bookmarks and notes
- Test: Concurrent tag updates from multiple sessions
- Test: Session persistence across browser refresh
- _Requirements: All_
- [ ]* 21.2 Write cross-browser compatibility tests
- Test on Chrome, Firefox, Safari, Edge
- Test HTMX functionality across browsers
- Test drag-and-drop across browsers
- _Requirements: 9.1, 9.2, 9.3, 9.4, 9.5_
- [ ]* 21.3 Write accessibility tests
- Test keyboard navigation
- Test screen reader compatibility
- Test WCAG compliance
- _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5, 10.6, 10.7_
- [~] 22. Final checkpoint - Ensure all tests pass
- Ensure all tests pass, ask the user if questions arise.
## Notes
- Tasks marked with `*` are optional testing tasks and can be skipped for faster MVP
- Each task references specific requirements for traceability
- Checkpoints ensure incremental validation at major milestones
- Property tests validate universal correctness properties with minimum 100 iterations
- Unit tests validate specific examples, edge cases, and error conditions
- The implementation follows an incremental approach: infrastructure → auth → core features → advanced features → polish → optimization
- All property tests should be tagged with: `Feature: custom-start-page, Property {number}: {property_text}`
- DynamoDB local should be used for development and testing
- Mock external services (OAuth providers, Weather API) in tests

57
Makefile Normal file
View File

@@ -0,0 +1,57 @@
.PHONY: help build run test clean dev db-start db-stop db-reset
# Go binary path
GO := /usr/local/go/bin/go
help: ## Show this help message
@echo 'Usage: make [target]'
@echo ''
@echo 'Available targets:'
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
build: ## Build the application
$(GO) build -o bin/server cmd/server/main.go
run: ## Run the application
$(GO) run cmd/server/main.go
test: ## Run all tests
$(GO) test -v ./...
test-coverage: ## Run tests with coverage
$(GO) test -v -coverprofile=coverage.out ./...
$(GO) tool cover -html=coverage.out -o coverage.html
clean: ## Clean build artifacts
rm -rf bin/
rm -f coverage.out coverage.html
dev: db-start ## Start development environment
@echo "Starting development server..."
$(GO) run cmd/server/main.go
db-start: ## Start DynamoDB local
@echo "Starting DynamoDB local..."
docker-compose up -d dynamodb-local
@echo "DynamoDB local is running on http://localhost:8000"
db-stop: ## Stop DynamoDB local
@echo "Stopping DynamoDB local..."
docker-compose down
db-reset: ## Reset DynamoDB local data
@echo "Resetting DynamoDB local..."
docker-compose down -v
docker-compose up -d dynamodb-local
deps: ## Download dependencies
$(GO) mod download
$(GO) mod tidy
fmt: ## Format code
$(GO) fmt ./...
lint: ## Run linter
golangci-lint run
.DEFAULT_GOAL := help

150
README.md Normal file
View File

@@ -0,0 +1,150 @@
# Custom Start Page Application
A personalized dashboard web application that allows users to create and manage custom start pages with configurable widgets, multiple pages, and customizable search functionality.
## Features
- **OAuth Authentication**: Secure login with Google OAuth (extensible to other providers)
- **Multi-Page Dashboard**: Organize widgets across multiple pages
- **Widget System**: Bookmark, Notes, and Weather widgets
- **Tag System**: Organize bookmarks and notes with tags
- **Group System**: Group bookmarks within widgets
- **Rich Content**: Support for plain text, RTF, code, YAML, and Markdown in notes
- **Sharing**: Share bookmarks and notes with others
- **Responsive Design**: Works on desktop, tablet, and mobile devices
## Technology Stack
- **Backend**: Go 1.26+
- **Frontend**: HTMX, Tailwind CSS, Vanilla JavaScript
- **Database**: AWS DynamoDB (with DynamoDB Local for development)
- **Testing**: Go testing package + gopter for property-based tests
## Prerequisites
- Go 1.26 or higher (installed at `/usr/local/go/bin/go`)
- Docker and Docker Compose (for DynamoDB Local)
- Google OAuth credentials (for authentication)
## Getting Started
### 1. Clone the repository
```bash
git clone <repository-url>
cd custom-start-page
```
### 2. Set up environment variables
```bash
cp .env.example .env
# Edit .env and add your Google OAuth credentials
```
### 3. Start DynamoDB Local
```bash
make db-start
```
### 4. Install dependencies
```bash
make deps
```
### 5. Run the application
```bash
make dev
```
The application will be available at `http://localhost:8080`
## Development
### Available Make Commands
- `make help` - Show available commands
- `make build` - Build the application
- `make run` - Run the application
- `make test` - Run all tests
- `make test-coverage` - Run tests with coverage report
- `make dev` - Start development environment (DynamoDB + server)
- `make db-start` - Start DynamoDB local
- `make db-stop` - Stop DynamoDB local
- `make db-reset` - Reset DynamoDB local data
- `make deps` - Download dependencies
- `make fmt` - Format code
- `make clean` - Clean build artifacts
### Project Structure
```
.
├── cmd/
│ └── server/ # Main application entry point
├── internal/
│ ├── auth/ # Authentication logic
│ ├── handlers/ # HTTP handlers
│ ├── models/ # Data models
│ ├── services/ # Business logic
│ ├── storage/ # Database layer
│ └── testing/ # Test helpers
├── pkg/
│ └── config/ # Configuration management
├── templates/
│ ├── layouts/ # Base HTML templates
│ ├── partials/ # Reusable template components
│ └── widgets/ # Widget templates
├── static/
│ ├── css/ # Stylesheets
│ ├── js/ # JavaScript files
│ └── images/ # Static images
├── docker-compose.yml # DynamoDB Local setup
├── Makefile # Development commands
└── README.md # This file
```
## Testing
The project uses both unit tests and property-based tests:
```bash
# Run all tests
make test
# Run tests with coverage
make test-coverage
```
Property-based tests use [gopter](https://github.com/leanovate/gopter) to verify correctness properties across many generated inputs.
## Configuration
Configuration is managed through environment variables. See `.env.example` for available options:
- `PORT` - Server port (default: 8080)
- `HOST` - Server host (default: localhost)
- `DYNAMODB_ENDPOINT` - DynamoDB endpoint (default: http://localhost:8000)
- `GOOGLE_CLIENT_ID` - Google OAuth client ID
- `GOOGLE_CLIENT_SECRET` - Google OAuth client secret
- `SESSION_SECRET` - Session encryption key
## Architecture
The application follows a hypermedia-driven architecture:
- **Server-side rendering**: HTML templates rendered by Go
- **HTMX**: Dynamic interactions without complex JavaScript
- **DynamoDB**: Scalable NoSQL database for user data
- **OAuth**: Secure authentication with external providers
## License
[Add your license here]
## Contributing
[Add contribution guidelines here]

176
SETUP.md Normal file
View File

@@ -0,0 +1,176 @@
# Project Setup Summary
This document summarizes the infrastructure setup completed for the Custom Start Page application.
## Completed Setup Tasks
### 1. Go Module Initialization
- ✅ Initialized Go module: `github.com/user/custom-start-page`
- ✅ Go version: 1.26.0 (installed at `/usr/local/go/bin/go`)
### 2. Directory Structure
```
.
├── cmd/
│ └── server/ # Main application entry point
├── internal/
│ ├── auth/ # Authentication logic (ready for implementation)
│ ├── handlers/ # HTTP handlers (ready for implementation)
│ ├── models/ # Data models (ready for implementation)
│ ├── services/ # Business logic (ready for implementation)
│ ├── storage/ # Database layer (ready for implementation)
│ └── testing/ # Test helpers (✅ implemented)
├── pkg/
│ └── config/ # Configuration management (✅ implemented)
├── templates/
│ ├── layouts/ # Base HTML templates (✅ base.html created)
│ ├── partials/ # Reusable components (ready for implementation)
│ ├── widgets/ # Widget templates (ready for implementation)
│ ├── login.html # ✅ Login page template
│ └── dashboard.html # ✅ Dashboard template
├── static/
│ ├── css/ # ✅ main.css with Tailwind utilities
│ ├── js/ # ✅ main.js with HTMX helpers
│ └── images/ # Static images (ready for use)
└── bin/ # Compiled binaries
```
### 3. Configuration Management
- ✅ Created `pkg/config/config.go` with environment variable support
- ✅ Configuration for:
- Server (host, port)
- Database (DynamoDB endpoint, region, table prefix)
- OAuth (Google client ID/secret, redirect URL)
- Session (secret key, max age)
- ✅ Comprehensive unit tests for configuration loading
### 4. DynamoDB Local Setup
- ✅ Created `docker-compose.yml` for DynamoDB Local
- ✅ Configured to run on port 8000
- ✅ Persistent volume for data storage
- ✅ Make commands for easy management:
- `make db-start` - Start DynamoDB Local
- `make db-stop` - Stop DynamoDB Local
- `make db-reset` - Reset DynamoDB Local data
### 5. Testing Framework
- ✅ Installed gopter (v0.2.11) for property-based testing
- ✅ Created test helpers in `internal/testing/helpers.go`
- ✅ Property test configuration with customizable parameters:
- MinSuccessfulTests: 100 (default)
- MaxSize: 100 (default)
- Workers: 4 (default)
- ✅ Comprehensive tests for test helpers
- ✅ All tests passing
### 6. HTML Templates with HTMX
- ✅ Base layout template (`templates/layouts/base.html`) with:
- HTMX 1.9.10
- Tailwind CSS (CDN)
- Sortable.js for drag-and-drop
- Prism.js for syntax highlighting (JavaScript, Python, Go)
- ✅ Login page template with OAuth provider buttons
- ✅ Dashboard template with:
- Top toolbar (search bar, page tabs, user menu)
- Widget grid with HTMX integration
- Sortable initialization for drag-and-drop
### 7. CSS Framework
- ✅ Tailwind CSS via CDN
- ✅ Custom CSS in `static/css/main.css`:
- Widget styles
- HTMX transition effects
- Tag styles
- Bookmark styles
- Notes editor styles
- Loading indicators
- Toast notifications
- Form styles
- Button styles
### 8. JavaScript Setup
- ✅ Created `static/js/main.js` with:
- Toast notification system
- HTMX event listeners for user feedback
- Prism.js integration for code highlighting
- Debounce helper for auto-save
- Search form validation
- Tag autocomplete helper
- Format selector for notes
- Confirm delete actions
### 9. Environment Variables
- ✅ Created `.env.example` with all required variables
- ✅ Configuration validation in code
- ✅ Sensible defaults for development
### 10. Development Tools
- ✅ Makefile with commands:
- `make help` - Show available commands
- `make build` - Build the application
- `make run` - Run the application
- `make test` - Run all tests
- `make test-coverage` - Run tests with coverage
- `make dev` - Start development environment
- `make db-start/stop/reset` - Manage DynamoDB Local
- `make deps` - Download dependencies
- `make fmt` - Format code
- `make clean` - Clean build artifacts
### 11. Documentation
- ✅ Comprehensive README.md
- ✅ .gitignore for Go projects
- ✅ This SETUP.md summary
### 12. Build Verification
- ✅ Application builds successfully
- ✅ Binary created: `bin/server` (7.7MB)
- ✅ All tests passing (config + testing helpers)
## Test Results
```
✅ pkg/config tests: 9/9 passed
✅ internal/testing tests: 3/3 passed
✅ Build: successful
```
## Next Steps
The infrastructure is now ready for implementing the application features:
1. **Task 2**: Implement authentication system
- User data model and DynamoDB table
- OAuth service with Google provider
- Session management
- Login and dashboard templates
2. **Task 3**: Implement core data models and DynamoDB integration
- Storage service wrapper
- All data models (Page, Widget, Bookmark, Note, etc.)
- DynamoDB table schemas
3. **Task 4+**: Continue with page management, widgets, tags, etc.
## Dependencies Installed
- `github.com/leanovate/gopter` v0.2.11 - Property-based testing
## Environment Setup Required
Before running the application, you need to:
1. Copy `.env.example` to `.env`
2. Add your Google OAuth credentials:
- `GOOGLE_CLIENT_ID`
- `GOOGLE_CLIENT_SECRET`
3. Start DynamoDB Local: `make db-start`
4. Run the application: `make dev`
## Notes
- Go binary path: `/usr/local/go/bin/go`
- DynamoDB Local endpoint: `http://localhost:8000`
- Server will run on: `http://localhost:8080`
- All configuration is environment-based (12-factor app)
- Testing framework supports both unit and property-based tests

33
cmd/init-db/main.go Normal file
View File

@@ -0,0 +1,33 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"custom-start-page/internal/storage"
)
func main() {
endpoint := flag.String("endpoint", "http://localhost:8000", "DynamoDB endpoint")
flag.Parse()
ctx := context.Background()
log.Printf("Connecting to DynamoDB at %s...", *endpoint)
db, err := storage.NewDynamoDBClient(ctx, *endpoint)
if err != nil {
log.Fatalf("Failed to create DynamoDB client: %v", err)
}
log.Println("Creating Users table...")
if err := db.CreateUsersTable(ctx); err != nil {
log.Fatalf("Failed to create Users table: %v", err)
}
log.Println("✓ Users table created successfully")
fmt.Println("\nDatabase initialization complete!")
os.Exit(0)
}

45
cmd/server/init_tables.go Normal file
View File

@@ -0,0 +1,45 @@
package main
import (
"context"
"fmt"
"log"
"os"
"custom-start-page/internal/storage"
)
// InitializeTables creates all required DynamoDB tables
func InitializeTables() error {
ctx := context.Background()
// Get DynamoDB endpoint from environment (for local development)
endpoint := os.Getenv("DYNAMODB_ENDPOINT")
if endpoint == "" {
endpoint = "http://localhost:8000" // Default for DynamoDB local
}
// Create DynamoDB client
db, err := storage.NewDynamoDBClient(ctx, endpoint)
if err != nil {
return fmt.Errorf("failed to create DynamoDB client: %w", err)
}
log.Println("Creating Users table...")
if err := db.CreateUsersTable(ctx); err != nil {
return fmt.Errorf("failed to create Users table: %w", err)
}
log.Println("Users table created successfully")
// TODO: Add other table creation calls here as they are implemented
// - Pages table
// - Widgets table
// - Bookmarks table
// - Notes table
// - TagAssociations table
// - Groups table
// - SharedItems table
// - Preferences table
return nil
}

99
cmd/server/main.go Normal file
View File

@@ -0,0 +1,99 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"custom-start-page/internal/auth"
"custom-start-page/internal/handlers"
"custom-start-page/internal/middleware"
"custom-start-page/internal/storage"
"custom-start-page/pkg/config"
)
func main() {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Initialize DynamoDB client
ctx := context.Background()
dbEndpoint := ""
if cfg.Database.UseLocalDB {
dbEndpoint = cfg.Database.Endpoint
}
dbClient, err := storage.NewDynamoDBClient(ctx, dbEndpoint)
if err != nil {
log.Fatalf("Failed to create DynamoDB client: %v", err)
}
// Create Users table if it doesn't exist
if err := dbClient.CreateUsersTable(ctx); err != nil {
log.Fatalf("Failed to create Users table: %v", err)
}
// Initialize repositories
userRepo := storage.NewUserRepository(dbClient, "Users")
// Initialize auth services
stateStore := auth.NewMemoryStateStore()
oauthService := auth.NewOAuthService(
cfg.OAuth.Google.ClientID,
cfg.OAuth.Google.ClientSecret,
cfg.OAuth.Google.RedirectURL,
stateStore,
)
userService := auth.NewUserService(userRepo)
sessionStore := auth.NewCookieSessionStore(cfg.Session.SecretKey, cfg.Session.MaxAge)
// Initialize handlers
authHandler := handlers.NewAuthHandler(oauthService, userService, sessionStore)
dashboardHandler := handlers.NewDashboardHandler()
// Setup routes
mux := http.NewServeMux()
// Static files
fs := http.FileServer(http.Dir("static"))
mux.Handle("/static/", http.StripPrefix("/static/", fs))
// Health check
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
// Auth routes
mux.HandleFunc("GET /login", authHandler.HandleLogin)
mux.HandleFunc("GET /auth/oauth/{provider}", authHandler.HandleOAuthInitiate)
mux.HandleFunc("GET /auth/callback/{provider}", authHandler.HandleOAuthCallback)
mux.HandleFunc("POST /logout", authHandler.HandleLogout)
// Root redirect
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
// Check if user is logged in
if userID, err := sessionStore.GetUserID(r); err == nil && userID != "" {
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
})
// Create auth middleware
requireAuth := middleware.RequireAuth(sessionStore)
// Protected dashboard route
mux.Handle("GET /dashboard", requireAuth(http.HandlerFunc(dashboardHandler.HandleDashboard)))
// Start server
addr := fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port)
log.Printf("Starting server on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}

15
docker-compose.yml Normal file
View File

@@ -0,0 +1,15 @@
version: '3.8'
services:
dynamodb-local:
image: amazon/dynamodb-local:latest
container_name: startpage-dynamodb-local
ports:
- "8000:8000"
command: "-jar DynamoDBLocal.jar -sharedDb -dbPath ./data"
volumes:
- dynamodb-data:/home/dynamodblocal/data
working_dir: /home/dynamodblocal
volumes:
dynamodb-data:

View File

@@ -0,0 +1,165 @@
# Task 2.1 Implementation: User Data Model and DynamoDB Users Table
## Overview
This task implements the User data model and DynamoDB Users table with full CRUD operations.
## Files Created
### 1. User Model (`internal/models/user.go`)
- Defines the `User` struct with all required fields:
- `ID` (user_id): Unique identifier (UUID)
- `Email`: User's email address
- `OAuthProvider`: OAuth provider name (e.g., "google", "github")
- `OAuthID`: OAuth provider's user ID
- `CreatedAt`: Timestamp when user was created
- `UpdatedAt`: Timestamp when user was last updated
### 2. DynamoDB Client (`internal/storage/dynamodb.go`)
- `NewDynamoDBClient`: Creates a new DynamoDB client with optional endpoint override
- `CreateUsersTable`: Creates the Users table with proper schema
- Partition Key: `user_id` (String)
- Billing Mode: Pay-per-request (on-demand)
- Waits for table to be active before returning
### 3. User Storage Layer (`internal/storage/user_storage.go`)
Implements all CRUD operations:
- `CreateUser`: Creates a new user with auto-generated UUID
- `GetUserByID`: Retrieves a user by their ID
- `GetUserByOAuth`: Retrieves a user by OAuth provider and ID
- `UpdateUser`: Updates an existing user (auto-updates UpdatedAt)
- `DeleteUser`: Deletes a user by their ID
### 4. Unit Tests (`internal/storage/user_storage_test.go`)
Comprehensive test coverage:
- `TestCreateUser`: Verifies user creation with all fields
- `TestGetUserByID`: Tests user retrieval by ID
- `TestGetUserByID_NotFound`: Tests error handling for non-existent users
- `TestGetUserByOAuth`: Tests user retrieval by OAuth credentials
- `TestGetUserByOAuth_NotFound`: Tests error handling for OAuth lookup
- `TestUpdateUser`: Verifies user updates and timestamp changes
- `TestDeleteUser`: Tests user deletion
- `TestCreateUser_MultipleUsers`: Verifies multiple users can be created with unique IDs
### 5. Database Initialization (`cmd/init-db/main.go`)
Standalone CLI tool to initialize DynamoDB tables:
```bash
go run cmd/init-db/main.go -endpoint http://localhost:8000
```
### 6. Table Initialization Helper (`cmd/server/init_tables.go`)
Helper function for the main server to initialize tables on startup.
## DynamoDB Schema
### Users Table
```
Table Name: Users
Partition Key: user_id (String)
Billing Mode: Pay-per-request
Attributes:
- user_id: String (UUID)
- email: String
- oauth_provider: String
- oauth_id: String
- created_at: Number (Unix timestamp in nanoseconds)
- updated_at: Number (Unix timestamp in nanoseconds)
```
## Dependencies Added
- `github.com/aws/aws-sdk-go-v2`: AWS SDK for Go v2
- `github.com/aws/aws-sdk-go-v2/config`: AWS configuration
- `github.com/aws/aws-sdk-go-v2/service/dynamodb`: DynamoDB service client
- `github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue`: DynamoDB attribute marshaling
- `github.com/google/uuid`: UUID generation
## Running Tests
### Prerequisites
1. Start DynamoDB Local:
```bash
make db-start
```
Or manually:
```bash
docker-compose up -d dynamodb-local
```
2. Set environment variable (optional):
```bash
export DYNAMODB_ENDPOINT=http://localhost:8000
```
### Run Tests
```bash
make test
# or
go test -v ./internal/storage/...
```
## Usage Example
```go
package main
import (
"context"
"log"
"custom-start-page/internal/storage"
)
func main() {
ctx := context.Background()
// Create DynamoDB client
db, err := storage.NewDynamoDBClient(ctx, "http://localhost:8000")
if err != nil {
log.Fatal(err)
}
// Create Users table
if err := db.CreateUsersTable(ctx); err != nil {
log.Fatal(err)
}
// Create user storage
userStorage := storage.NewUserStorage(db)
// Create a new user
user, err := userStorage.CreateUser(ctx, "user@example.com", "google", "google123")
if err != nil {
log.Fatal(err)
}
log.Printf("Created user: %+v", user)
// Retrieve user by ID
retrievedUser, err := userStorage.GetUserByID(ctx, user.ID)
if err != nil {
log.Fatal(err)
}
log.Printf("Retrieved user: %+v", retrievedUser)
}
```
## Notes
### OAuth Lookup Performance
The `GetUserByOAuth` method currently uses a Scan operation since there's no GSI for `oauth_provider` + `oauth_id`. For production use with many users, consider adding a GSI:
- Partition Key: `oauth_provider`
- Sort Key: `oauth_id`
This would change the query from O(n) scan to O(1) lookup.
### Timestamp Storage
Timestamps are stored as Go `time.Time` which DynamoDB marshals as Unix timestamps in nanoseconds. This provides high precision for audit trails.
## Requirements Validated
- ✓ Requirement 1.1: User authentication foundation
- ✓ Requirement 1.3: OAuth user creation and retrieval
- ✓ Requirement 8.7: User data association
## Next Steps
Task 2.2 will implement the OAuth service using this User model to handle Google OAuth authentication.

View File

@@ -0,0 +1,160 @@
# Task 2.2: OAuth Service Implementation
## Overview
This document describes the implementation of the OAuth service with Google provider support for the Custom Start Page application.
## Components Implemented
### 1. OAuth Service (`internal/auth/oauth.go`)
- **Purpose**: Manages OAuth authentication flows
- **Key Methods**:
- `InitiateOAuth(provider string)`: Generates OAuth redirect URL with CSRF protection
- `HandleOAuthCallback(ctx, provider, code, state)`: Exchanges authorization code for access token
- `GetGoogleConfig()`: Returns OAuth configuration for accessing user info
- **Features**:
- CSRF protection using state tokens
- Support for Google OAuth (extensible for other providers)
- Secure state token generation using crypto/rand
### 2. State Store (`internal/auth/state_store.go`)
- **Purpose**: Manages OAuth state tokens for CSRF protection
- **Implementation**: In-memory store with automatic cleanup
- **Key Methods**:
- `Set(state, expiry)`: Stores state token with expiration
- `Validate(state)`: Checks if state token is valid and not expired
- `Delete(state)`: Removes state token after use
- **Features**:
- Automatic cleanup of expired tokens every 5 minutes
- Thread-safe with mutex protection
- 10-minute token expiry for security
### 3. User Service (`internal/auth/user_service.go`)
- **Purpose**: Handles user creation and retrieval from OAuth providers
- **Key Methods**:
- `GetOrCreateUserFromGoogle(ctx, token, config)`: Fetches user info and creates/updates user
- `fetchGoogleUserInfo(ctx, token, config)`: Retrieves user data from Google API
- **Features**:
- Automatic user creation on first login
- Email verification check
- Updates last login timestamp
### 4. Session Store (`internal/auth/session_store.go`)
- **Purpose**: Manages user sessions using cookies
- **Implementation**: Cookie-based sessions using gorilla/sessions
- **Key Methods**:
- `CreateSession(w, r, userID)`: Creates authenticated session
- `GetUserID(r)`: Retrieves user ID from session
- `DestroySession(w, r)`: Logs out user
- **Features**:
- Secure, HTTP-only cookies
- Configurable session expiry (default: 7 days)
- SameSite protection
### 5. User Repository (`internal/storage/user_repository.go`)
- **Purpose**: Handles user data persistence in DynamoDB
- **Key Methods**:
- `Create(ctx, user)`: Creates new user
- `GetByID(ctx, userID)`: Retrieves user by ID
- `GetByOAuthID(ctx, provider, oauthID)`: Finds user by OAuth credentials
- `Update(ctx, user)`: Updates existing user
- **Note**: Currently uses Scan for OAuth lookup; consider adding GSI in production
### 6. Auth Handler (`internal/handlers/auth_handler.go`)
- **Purpose**: HTTP handlers for authentication endpoints
- **Endpoints**:
- `GET /auth/oauth/{provider}`: Initiates OAuth flow
- `GET /auth/callback/{provider}`: Handles OAuth callback
- `POST /logout`: Logs out user
- `GET /login`: Displays login page
- **Features**:
- Error handling with user-friendly messages
- Automatic redirect to dashboard on success
- Session management integration
## HTTP Endpoints
### OAuth Flow
1. **Initiate OAuth**: `GET /auth/oauth/google`
- Generates state token
- Redirects to Google OAuth consent screen
2. **OAuth Callback**: `GET /auth/callback/google?code=...&state=...`
- Validates state token (CSRF protection)
- Exchanges code for access token
- Fetches user info from Google
- Creates or retrieves user account
- Creates session
- Redirects to dashboard
3. **Logout**: `POST /logout`
- Destroys session
- Redirects to login page
4. **Login Page**: `GET /login`
- Displays OAuth provider buttons
- Shows error messages if authentication failed
## Configuration
Required environment variables:
```bash
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GOOGLE_REDIRECT_URL=http://localhost:8080/auth/callback/google
SESSION_SECRET=change-me-in-production
SESSION_MAX_AGE=604800 # 7 days in seconds
```
## Security Features
1. **CSRF Protection**: State tokens prevent cross-site request forgery
2. **Secure Sessions**: HTTP-only, secure cookies with SameSite protection
3. **Email Verification**: Only verified Google emails are accepted
4. **Token Expiry**: State tokens expire after 10 minutes
5. **Cryptographic Randomness**: State tokens use crypto/rand for security
## Testing
Comprehensive test coverage includes:
- OAuth service initialization and callback handling
- State store operations (set, validate, delete, expiry)
- Session store operations (create, retrieve, destroy)
- Error handling for invalid states and codes
- Concurrent state management
Run tests:
```bash
go test ./internal/auth/... -v
```
## Integration with Main Server
The OAuth service is integrated into `cmd/server/main.go`:
- Initializes DynamoDB client and creates Users table
- Sets up OAuth service with Google configuration
- Creates session store with configured secret
- Registers auth handlers for OAuth endpoints
- Protects dashboard route with session check
## Requirements Validated
This implementation addresses the following requirements:
- **1.2**: OAuth provider selection triggers redirect
- **1.3**: Successful OAuth creates or retrieves user
- **1.5**: Google OAuth support
- **1.6**: Additional OAuth providers can be configured (extensible design)
## Future Enhancements
1. **Production State Store**: Replace in-memory store with Redis or DynamoDB for multi-server deployments
2. **OAuth Provider GSI**: Add Global Secondary Index on oauth_provider + oauth_id for efficient user lookup
3. **Additional Providers**: Add GitHub, Microsoft, and other OAuth providers
4. **Rate Limiting**: Add rate limiting on OAuth endpoints
5. **Audit Logging**: Log authentication events for security monitoring
## Dependencies Added
- `golang.org/x/oauth2`: OAuth 2.0 client library
- `golang.org/x/oauth2/google`: Google OAuth provider
- `github.com/gorilla/sessions`: Session management
- `github.com/gorilla/securecookie`: Secure cookie encoding (dependency of sessions)

View File

@@ -0,0 +1,162 @@
# Task 3.1 Implementation: DynamoDB Storage Service Wrapper
## Overview
Enhanced the existing DynamoDB client from Task 2.1 with retry logic, connection pooling, transaction support, and batch operations.
## Implementation Details
### 1. Enhanced Client Configuration
- **Retry Strategy**: Configured with exponential backoff and jitter
- Max attempts: 5
- Max backoff: 20 seconds
- Prevents thundering herd with jitter
- **Connection Pooling**: Uses AWS SDK's default HTTP client with built-in connection pooling
### 2. Transaction Support
Implemented `TransactWriteItems` method for ACID transactions:
- Supports multiple write operations in a single atomic transaction
- Automatic retry on transient failures
- Proper error handling and wrapping
### 3. Batch Operations
Implemented two batch operation methods:
#### BatchGetItems
- Retrieves multiple items in a single request
- Automatically retries unprocessed keys with exponential backoff
- Merges results from retry attempts
- Max 5 retry attempts with increasing backoff (100ms → 20s)
#### BatchWriteItems
- Writes multiple items in a single request
- Automatically retries unprocessed items with exponential backoff
- Max 5 retry attempts with increasing backoff (100ms → 20s)
### 4. Standard Operations
Wrapped standard DynamoDB operations with automatic retry:
- `PutItem` - Put a single item
- `GetItem` - Get a single item
- `UpdateItem` - Update a single item
- `DeleteItem` - Delete a single item
- `Query` - Query items
All operations include proper error handling and wrapping.
### 5. Comprehensive Testing
Created `dynamodb_test.go` with tests for:
- Client initialization
- Transaction operations
- Batch get operations
- Batch write operations
- Put and get operations
- Update operations
- Delete operations
- Query operations
Tests include:
- Automatic skip when DynamoDB is not available
- Helper function for test setup with dummy AWS credentials
- Table creation and cleanup helpers
- Verification of all operations
## Files Modified/Created
### Modified
- `internal/storage/dynamodb.go` - Enhanced with retry logic, transactions, and batch operations
### Created
- `internal/storage/dynamodb_test.go` - Comprehensive test suite
- `internal/storage/README.md` - Documentation for the storage service
- `docs/task-3.1-implementation.md` - This implementation document
## Requirements Addressed
**Requirement 8.1**: Immediate persistence with reliable operations
**Requirement 8.8**: Efficient scaling through batch operations and connection pooling
**Design**: Retry logic with exponential backoff
**Design**: Transaction support (TransactWrite)
**Design**: Batch operations (BatchGet, BatchWrite)
**Design**: Connection pooling
## Testing
### Running Tests
```bash
# Start DynamoDB Local
docker-compose up -d
# Run all storage tests
go test -v ./internal/storage
# Run specific tests
go test -v ./internal/storage -run TestTransactWriteItems
```
### Test Coverage
- ✅ Client initialization with retry configuration
- ✅ Transaction writes with multiple items
- ✅ Batch get with multiple items
- ✅ Batch write with multiple items
- ✅ Single item operations (Put, Get, Update, Delete)
- ✅ Query operations
- ✅ Graceful skip when DynamoDB unavailable
## Usage Example
```go
// Create client
ctx := context.Background()
client, err := storage.NewDynamoDBClient(ctx, "http://localhost:8000")
if err != nil {
log.Fatal(err)
}
// Transaction example
err = client.TransactWriteItems(ctx, &dynamodb.TransactWriteItemsInput{
TransactItems: []types.TransactWriteItem{
{
Put: &types.Put{
TableName: aws.String("MyTable"),
Item: map[string]types.AttributeValue{
"id": &types.AttributeValueMemberS{Value: "item1"},
},
},
},
},
})
// Batch write example
err = client.BatchWriteItems(ctx, &dynamodb.BatchWriteItemInput{
RequestItems: map[string][]types.WriteRequest{
"MyTable": {
{
PutRequest: &types.PutRequest{
Item: map[string]types.AttributeValue{
"id": &types.AttributeValueMemberS{Value: "item1"},
},
},
},
},
},
})
// Batch get example
output, err := client.BatchGetItems(ctx, &dynamodb.BatchGetItemInput{
RequestItems: map[string]types.KeysAndAttributes{
"MyTable": {
Keys: []map[string]types.AttributeValue{
{"id": &types.AttributeValueMemberS{Value: "item1"}},
},
},
},
})
```
## Next Steps
This enhanced storage service is now ready to be used by:
- Task 3.2: Data model implementations
- Task 3.3: Table schema creation
- All future tasks requiring DynamoDB operations
The retry logic, transaction support, and batch operations provide a solid foundation for building scalable, reliable data access patterns.

34
go.mod Normal file
View File

@@ -0,0 +1,34 @@
module custom-start-page
go 1.24.0
require (
github.com/aws/aws-sdk-go-v2 v1.24.0
github.com/aws/aws-sdk-go-v2/config v1.26.1
github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.12.13
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.26.7
github.com/google/uuid v1.5.0
github.com/leanovate/gopter v0.2.11
)
require (
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.16.12 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect
github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.18.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.8.10 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 // indirect
github.com/aws/smithy-go v1.19.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sys v0.35.0 // indirect
)

109
internal/auth/oauth.go Normal file
View File

@@ -0,0 +1,109 @@
package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
// OAuthService handles OAuth authentication flows
type OAuthService struct {
googleConfig *oauth2.Config
stateStore StateStore
}
// StateStore manages OAuth state tokens for CSRF protection
type StateStore interface {
Set(state string, expiry time.Time) error
Validate(state string) (bool, error)
Delete(state string) error
}
// NewOAuthService creates a new OAuth service
func NewOAuthService(googleClientID, googleClientSecret, googleRedirectURL string, stateStore StateStore) *OAuthService {
googleConfig := &oauth2.Config{
ClientID: googleClientID,
ClientSecret: googleClientSecret,
RedirectURL: googleRedirectURL,
Scopes: []string{
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
},
Endpoint: google.Endpoint,
}
return &OAuthService{
googleConfig: googleConfig,
stateStore: stateStore,
}
}
// InitiateOAuth starts the OAuth flow and returns the redirect URL
func (s *OAuthService) InitiateOAuth(provider string) (string, error) {
if provider != "google" {
return "", fmt.Errorf("unsupported OAuth provider: %s", provider)
}
// Generate random state token for CSRF protection
state, err := generateStateToken()
if err != nil {
return "", fmt.Errorf("failed to generate state token: %w", err)
}
// Store state with 10 minute expiry
expiry := time.Now().Add(10 * time.Minute)
if err := s.stateStore.Set(state, expiry); err != nil {
return "", fmt.Errorf("failed to store state token: %w", err)
}
// Generate authorization URL
url := s.googleConfig.AuthCodeURL(state, oauth2.AccessTypeOffline)
return url, nil
}
// HandleOAuthCallback processes the OAuth callback and exchanges the code for a token
func (s *OAuthService) HandleOAuthCallback(ctx context.Context, provider, code, state string) (*oauth2.Token, error) {
if provider != "google" {
return nil, fmt.Errorf("unsupported OAuth provider: %s", provider)
}
// Validate state token
valid, err := s.stateStore.Validate(state)
if err != nil {
return nil, fmt.Errorf("failed to validate state token: %w", err)
}
if !valid {
return nil, fmt.Errorf("invalid or expired state token")
}
// Delete state token after validation
_ = s.stateStore.Delete(state)
// Exchange authorization code for token
token, err := s.googleConfig.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("failed to exchange code for token: %w", err)
}
return token, nil
}
// GetGoogleConfig returns the Google OAuth config for accessing user info
func (s *OAuthService) GetGoogleConfig() *oauth2.Config {
return s.googleConfig
}
// generateStateToken generates a cryptographically secure random state token
func generateStateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}

136
internal/auth/oauth_test.go Normal file
View File

@@ -0,0 +1,136 @@
package auth
import (
"context"
"strings"
"testing"
"time"
)
func TestInitiateOAuth(t *testing.T) {
stateStore := NewMemoryStateStore()
service := NewOAuthService(
"test-client-id",
"test-client-secret",
"http://localhost:8080/auth/callback/google",
stateStore,
)
t.Run("successful OAuth initiation", func(t *testing.T) {
redirectURL, err := service.InitiateOAuth("google")
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if redirectURL == "" {
t.Fatal("Expected redirect URL, got empty string")
}
// Check that URL contains expected components
if !strings.Contains(redirectURL, "accounts.google.com") {
t.Errorf("Expected Google OAuth URL, got %s", redirectURL)
}
if !strings.Contains(redirectURL, "client_id=test-client-id") {
t.Errorf("Expected client_id in URL, got %s", redirectURL)
}
if !strings.Contains(redirectURL, "redirect_uri=") {
t.Errorf("Expected redirect_uri in URL, got %s", redirectURL)
}
if !strings.Contains(redirectURL, "state=") {
t.Errorf("Expected state parameter in URL, got %s", redirectURL)
}
})
t.Run("unsupported provider", func(t *testing.T) {
_, err := service.InitiateOAuth("unsupported")
if err == nil {
t.Fatal("Expected error for unsupported provider, got nil")
}
if !strings.Contains(err.Error(), "unsupported OAuth provider") {
t.Errorf("Expected unsupported provider error, got %v", err)
}
})
}
func TestHandleOAuthCallback(t *testing.T) {
stateStore := NewMemoryStateStore()
service := NewOAuthService(
"test-client-id",
"test-client-secret",
"http://localhost:8080/auth/callback/google",
stateStore,
)
t.Run("invalid state token", func(t *testing.T) {
ctx := context.Background()
_, err := service.HandleOAuthCallback(ctx, "google", "test-code", "invalid-state")
if err == nil {
t.Fatal("Expected error for invalid state, got nil")
}
if !strings.Contains(err.Error(), "invalid or expired state token") {
t.Errorf("Expected invalid state error, got %v", err)
}
})
t.Run("valid state token but invalid code", func(t *testing.T) {
// Store a valid state token
state := "valid-state-token"
expiry := time.Now().Add(10 * time.Minute)
if err := stateStore.Set(state, expiry); err != nil {
t.Fatalf("Failed to set state: %v", err)
}
ctx := context.Background()
_, err := service.HandleOAuthCallback(ctx, "google", "invalid-code", state)
// This should fail because the code is invalid (can't exchange with Google)
if err == nil {
t.Fatal("Expected error for invalid code, got nil")
}
// The state should be deleted even on error
valid, _ := stateStore.Validate(state)
if valid {
t.Error("Expected state to be deleted after callback")
}
})
t.Run("unsupported provider", func(t *testing.T) {
ctx := context.Background()
_, err := service.HandleOAuthCallback(ctx, "unsupported", "test-code", "test-state")
if err == nil {
t.Fatal("Expected error for unsupported provider, got nil")
}
if !strings.Contains(err.Error(), "unsupported OAuth provider") {
t.Errorf("Expected unsupported provider error, got %v", err)
}
})
}
func TestGenerateStateToken(t *testing.T) {
t.Run("generates unique tokens", func(t *testing.T) {
token1, err := generateStateToken()
if err != nil {
t.Fatalf("Failed to generate token: %v", err)
}
token2, err := generateStateToken()
if err != nil {
t.Fatalf("Failed to generate token: %v", err)
}
if token1 == token2 {
t.Error("Expected unique tokens, got duplicates")
}
if len(token1) == 0 {
t.Error("Expected non-empty token")
}
})
}

View File

@@ -0,0 +1,88 @@
package auth
import (
"fmt"
"net/http"
"github.com/gorilla/sessions"
)
const sessionName = "startpage_session"
const userIDKey = "user_id"
// CookieSessionStore implements SessionStore using gorilla/sessions
type CookieSessionStore struct {
store *sessions.CookieStore
}
// NewCookieSessionStore creates a new cookie-based session store
func NewCookieSessionStore(secretKey string, maxAge int) *CookieSessionStore {
store := sessions.NewCookieStore([]byte(secretKey))
store.Options = &sessions.Options{
Path: "/",
MaxAge: maxAge,
HttpOnly: true,
Secure: false, // Set to true in production with HTTPS
SameSite: http.SameSiteLaxMode,
}
return &CookieSessionStore{
store: store,
}
}
// CreateSession creates a new session for the user
func (s *CookieSessionStore) CreateSession(w http.ResponseWriter, r *http.Request, userID string) error {
session, err := s.store.Get(r, sessionName)
if err != nil {
// If there's an error getting the session, create a new one
session, _ = s.store.New(r, sessionName)
}
session.Values[userIDKey] = userID
if err := session.Save(r, w); err != nil {
return fmt.Errorf("failed to save session: %w", err)
}
return nil
}
// GetUserID retrieves the user ID from the session
func (s *CookieSessionStore) GetUserID(r *http.Request) (string, error) {
session, err := s.store.Get(r, sessionName)
if err != nil {
return "", fmt.Errorf("failed to get session: %w", err)
}
userID, ok := session.Values[userIDKey].(string)
if !ok || userID == "" {
return "", fmt.Errorf("user ID not found in session")
}
return userID, nil
}
// ValidateSession checks if a valid session exists for the request
func (s *CookieSessionStore) ValidateSession(r *http.Request) bool {
_, err := s.GetUserID(r)
return err == nil
}
// DestroySession destroys the user's session
func (s *CookieSessionStore) DestroySession(w http.ResponseWriter, r *http.Request) error {
session, err := s.store.Get(r, sessionName)
if err != nil {
// Session doesn't exist or is invalid, nothing to destroy
return nil
}
// Set MaxAge to -1 to delete the cookie
session.Options.MaxAge = -1
if err := session.Save(r, w); err != nil {
return fmt.Errorf("failed to destroy session: %w", err)
}
return nil
}

View File

@@ -0,0 +1,237 @@
package auth
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestCookieSessionStore(t *testing.T) {
store := NewCookieSessionStore("test-secret-key", 3600)
t.Run("create and retrieve session", func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
userID := "test-user-123"
err := store.CreateSession(w, r, userID)
if err != nil {
t.Fatalf("Failed to create session: %v", err)
}
// Get the cookie from the response
cookies := w.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("Expected session cookie, got none")
}
// Create a new request with the cookie
r2 := httptest.NewRequest("GET", "/", nil)
for _, cookie := range cookies {
r2.AddCookie(cookie)
}
retrievedUserID, err := store.GetUserID(r2)
if err != nil {
t.Fatalf("Failed to get user ID: %v", err)
}
if retrievedUserID != userID {
t.Errorf("Expected user ID %s, got %s", userID, retrievedUserID)
}
})
t.Run("get user ID without session", func(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
_, err := store.GetUserID(r)
if err == nil {
t.Error("Expected error when getting user ID without session")
}
})
t.Run("destroy session", func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
userID := "test-user-456"
err := store.CreateSession(w, r, userID)
if err != nil {
t.Fatalf("Failed to create session: %v", err)
}
// Get the cookie
cookies := w.Result().Cookies()
r2 := httptest.NewRequest("GET", "/", nil)
for _, cookie := range cookies {
r2.AddCookie(cookie)
}
// Destroy the session
w2 := httptest.NewRecorder()
err = store.DestroySession(w2, r2)
if err != nil {
t.Fatalf("Failed to destroy session: %v", err)
}
// Check that the cookie has MaxAge set to -1 (deletion marker)
destroyCookies := w2.Result().Cookies()
if len(destroyCookies) == 0 {
t.Fatal("Expected cookie with MaxAge=-1 for deletion")
}
foundDeleteCookie := false
for _, cookie := range destroyCookies {
if cookie.MaxAge == -1 {
foundDeleteCookie = true
break
}
}
if !foundDeleteCookie {
t.Error("Expected cookie with MaxAge=-1 to indicate deletion")
}
})
t.Run("update existing session", func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
// Create initial session
userID1 := "user-1"
store.CreateSession(w, r, userID1)
cookies := w.Result().Cookies()
r2 := httptest.NewRequest("GET", "/", nil)
for _, cookie := range cookies {
r2.AddCookie(cookie)
}
// Update session with new user ID
w2 := httptest.NewRecorder()
userID2 := "user-2"
err := store.CreateSession(w2, r2, userID2)
if err != nil {
t.Fatalf("Failed to update session: %v", err)
}
// Verify new user ID
cookies2 := w2.Result().Cookies()
r3 := httptest.NewRequest("GET", "/", nil)
for _, cookie := range cookies2 {
r3.AddCookie(cookie)
}
retrievedUserID, err := store.GetUserID(r3)
if err != nil {
t.Fatalf("Failed to get user ID: %v", err)
}
if retrievedUserID != userID2 {
t.Errorf("Expected user ID %s, got %s", userID2, retrievedUserID)
}
})
t.Run("validate session with valid session", func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
userID := "test-user-789"
err := store.CreateSession(w, r, userID)
if err != nil {
t.Fatalf("Failed to create session: %v", err)
}
// Create request with session cookie
cookies := w.Result().Cookies()
r2 := httptest.NewRequest("GET", "/", nil)
for _, cookie := range cookies {
r2.AddCookie(cookie)
}
// Validate session
if !store.ValidateSession(r2) {
t.Error("Expected ValidateSession to return true for valid session")
}
})
t.Run("validate session without session", func(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
// Validate session without any cookies
if store.ValidateSession(r) {
t.Error("Expected ValidateSession to return false for missing session")
}
})
t.Run("validate session after logout", func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
userID := "test-user-logout"
err := store.CreateSession(w, r, userID)
if err != nil {
t.Fatalf("Failed to create session: %v", err)
}
// Get the cookie
cookies := w.Result().Cookies()
r2 := httptest.NewRequest("GET", "/", nil)
for _, cookie := range cookies {
r2.AddCookie(cookie)
}
// Destroy the session
w2 := httptest.NewRecorder()
err = store.DestroySession(w2, r2)
if err != nil {
t.Fatalf("Failed to destroy session: %v", err)
}
// Create a new request without any cookies (simulating browser behavior after logout)
r3 := httptest.NewRequest("GET", "/", nil)
// Validate session should return false
if store.ValidateSession(r3) {
t.Error("Expected ValidateSession to return false after logout")
}
})
t.Run("session cookie has security settings", func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
userID := "test-user-security"
err := store.CreateSession(w, r, userID)
if err != nil {
t.Fatalf("Failed to create session: %v", err)
}
cookies := w.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("Expected session cookie, got none")
}
cookie := cookies[0]
// Verify HttpOnly flag is set
if !cookie.HttpOnly {
t.Error("Expected HttpOnly flag to be true")
}
// Verify SameSite is set
if cookie.SameSite != http.SameSiteLaxMode {
t.Errorf("Expected SameSite to be Lax, got %v", cookie.SameSite)
}
// Verify Path is set
if cookie.Path != "/" {
t.Errorf("Expected Path to be /, got %s", cookie.Path)
}
// Verify MaxAge is set
if cookie.MaxAge != 3600 {
t.Errorf("Expected MaxAge to be 3600, got %d", cookie.MaxAge)
}
})
}

View File

@@ -0,0 +1,78 @@
package auth
import (
"sync"
"time"
)
// MemoryStateStore is an in-memory implementation of StateStore
// Note: This is suitable for development but should be replaced with
// a distributed store (Redis, DynamoDB) for production with multiple servers
type MemoryStateStore struct {
mu sync.RWMutex
states map[string]time.Time
}
// NewMemoryStateStore creates a new in-memory state store
func NewMemoryStateStore() *MemoryStateStore {
store := &MemoryStateStore{
states: make(map[string]time.Time),
}
// Start cleanup goroutine to remove expired states
go store.cleanupExpired()
return store
}
// Set stores a state token with an expiry time
func (s *MemoryStateStore) Set(state string, expiry time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
s.states[state] = expiry
return nil
}
// Validate checks if a state token is valid and not expired
func (s *MemoryStateStore) Validate(state string) (bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
expiry, exists := s.states[state]
if !exists {
return false, nil
}
if time.Now().After(expiry) {
return false, nil
}
return true, nil
}
// Delete removes a state token from the store
func (s *MemoryStateStore) Delete(state string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.states, state)
return nil
}
// cleanupExpired periodically removes expired state tokens
func (s *MemoryStateStore) cleanupExpired() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
s.mu.Lock()
now := time.Now()
for state, expiry := range s.states {
if now.After(expiry) {
delete(s.states, state)
}
}
s.mu.Unlock()
}
}

View File

@@ -0,0 +1,112 @@
package auth
import (
"testing"
"time"
)
func TestMemoryStateStore(t *testing.T) {
store := NewMemoryStateStore()
t.Run("set and validate state", func(t *testing.T) {
state := "test-state-123"
expiry := time.Now().Add(10 * time.Minute)
err := store.Set(state, expiry)
if err != nil {
t.Fatalf("Failed to set state: %v", err)
}
valid, err := store.Validate(state)
if err != nil {
t.Fatalf("Failed to validate state: %v", err)
}
if !valid {
t.Error("Expected state to be valid")
}
})
t.Run("validate non-existent state", func(t *testing.T) {
valid, err := store.Validate("non-existent")
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if valid {
t.Error("Expected non-existent state to be invalid")
}
})
t.Run("validate expired state", func(t *testing.T) {
state := "expired-state"
expiry := time.Now().Add(-1 * time.Minute) // Already expired
err := store.Set(state, expiry)
if err != nil {
t.Fatalf("Failed to set state: %v", err)
}
valid, err := store.Validate(state)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if valid {
t.Error("Expected expired state to be invalid")
}
})
t.Run("delete state", func(t *testing.T) {
state := "delete-test"
expiry := time.Now().Add(10 * time.Minute)
err := store.Set(state, expiry)
if err != nil {
t.Fatalf("Failed to set state: %v", err)
}
err = store.Delete(state)
if err != nil {
t.Fatalf("Failed to delete state: %v", err)
}
valid, err := store.Validate(state)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if valid {
t.Error("Expected deleted state to be invalid")
}
})
t.Run("multiple states", func(t *testing.T) {
state1 := "state-1"
state2 := "state-2"
expiry := time.Now().Add(10 * time.Minute)
store.Set(state1, expiry)
store.Set(state2, expiry)
valid1, _ := store.Validate(state1)
valid2, _ := store.Validate(state2)
if !valid1 || !valid2 {
t.Error("Expected both states to be valid")
}
store.Delete(state1)
valid1, _ = store.Validate(state1)
valid2, _ = store.Validate(state2)
if valid1 {
t.Error("Expected state1 to be invalid after deletion")
}
if !valid2 {
t.Error("Expected state2 to still be valid")
}
})
}

View File

@@ -0,0 +1,100 @@
package auth
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/google/uuid"
"golang.org/x/oauth2"
"custom-start-page/internal/models"
"custom-start-page/internal/storage"
)
// GoogleUserInfo represents the user info returned by Google
type GoogleUserInfo struct {
ID string `json:"id"`
Email string `json:"email"`
VerifiedEmail bool `json:"verified_email"`
Name string `json:"name"`
Picture string `json:"picture"`
}
// UserService handles user creation and retrieval
type UserService struct {
userRepo *storage.UserRepository
}
// NewUserService creates a new user service
func NewUserService(userRepo *storage.UserRepository) *UserService {
return &UserService{
userRepo: userRepo,
}
}
// GetOrCreateUserFromGoogle fetches user info from Google and creates or retrieves the user
func (s *UserService) GetOrCreateUserFromGoogle(ctx context.Context, token *oauth2.Token, oauthConfig *oauth2.Config) (*models.User, error) {
// Fetch user info from Google
userInfo, err := s.fetchGoogleUserInfo(ctx, token, oauthConfig)
if err != nil {
return nil, fmt.Errorf("failed to fetch Google user info: %w", err)
}
// Check if user already exists
user, err := s.userRepo.GetByOAuthID(ctx, "google", userInfo.ID)
if err == nil {
// User exists, update last login time
user.UpdatedAt = time.Now()
if err := s.userRepo.Update(ctx, user); err != nil {
return nil, fmt.Errorf("failed to update user: %w", err)
}
return user, nil
}
// User doesn't exist, create new user
user = &models.User{
ID: uuid.New().String(),
Email: userInfo.Email,
OAuthProvider: "google",
OAuthID: userInfo.ID,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.userRepo.Create(ctx, user); err != nil {
return nil, fmt.Errorf("failed to create user: %w", err)
}
return user, nil
}
// fetchGoogleUserInfo fetches user information from Google's userinfo endpoint
func (s *UserService) fetchGoogleUserInfo(ctx context.Context, token *oauth2.Token, oauthConfig *oauth2.Config) (*GoogleUserInfo, error) {
client := oauthConfig.Client(ctx, token)
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
if err != nil {
return nil, fmt.Errorf("failed to get user info: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("failed to get user info: status %d, body: %s", resp.StatusCode, string(body))
}
var userInfo GoogleUserInfo
if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
return nil, fmt.Errorf("failed to decode user info: %w", err)
}
if !userInfo.VerifiedEmail {
return nil, fmt.Errorf("email not verified")
}
return &userInfo, nil
}

View File

@@ -0,0 +1,170 @@
package handlers
import (
"html/template"
"log"
"net/http"
"path/filepath"
"custom-start-page/internal/auth"
)
// AuthHandler handles authentication-related HTTP requests
type AuthHandler struct {
oauthService *auth.OAuthService
userService *auth.UserService
sessionStore SessionStore
templates *template.Template
}
// SessionStore manages user sessions
type SessionStore interface {
CreateSession(w http.ResponseWriter, r *http.Request, userID string) error
GetUserID(r *http.Request) (string, error)
DestroySession(w http.ResponseWriter, r *http.Request) error
}
// NewAuthHandler creates a new auth handler
func NewAuthHandler(oauthService *auth.OAuthService, userService *auth.UserService, sessionStore SessionStore) *AuthHandler {
return NewAuthHandlerWithTemplates(oauthService, userService, sessionStore, nil)
}
// NewAuthHandlerWithTemplates creates a new auth handler with custom templates
func NewAuthHandlerWithTemplates(oauthService *auth.OAuthService, userService *auth.UserService, sessionStore SessionStore, templates *template.Template) *AuthHandler {
if templates == nil {
// Parse templates
templates = template.Must(template.ParseGlob(filepath.Join("templates", "*.html")))
template.Must(templates.ParseGlob(filepath.Join("templates", "layouts", "*.html")))
}
return &AuthHandler{
oauthService: oauthService,
userService: userService,
sessionStore: sessionStore,
templates: templates,
}
}
// HandleOAuthInitiate initiates the OAuth flow
// GET /auth/oauth/:provider
func (h *AuthHandler) HandleOAuthInitiate(w http.ResponseWriter, r *http.Request) {
// Extract provider from URL path
provider := r.PathValue("provider")
if provider == "" {
http.Error(w, "Provider not specified", http.StatusBadRequest)
return
}
// Generate OAuth redirect URL
redirectURL, err := h.oauthService.InitiateOAuth(provider)
if err != nil {
log.Printf("Failed to initiate OAuth: %v", err)
http.Error(w, "Failed to initiate OAuth", http.StatusInternalServerError)
return
}
// Redirect to OAuth provider
http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
}
// HandleOAuthCallback handles the OAuth callback
// GET /auth/callback/:provider
func (h *AuthHandler) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) {
// Extract provider from URL path
provider := r.PathValue("provider")
if provider == "" {
http.Error(w, "Provider not specified", http.StatusBadRequest)
return
}
// Get code and state from query parameters
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
if code == "" {
// Check for error from OAuth provider
if errMsg := r.URL.Query().Get("error"); errMsg != "" {
log.Printf("OAuth error: %s", errMsg)
http.Redirect(w, r, "/login?error=oauth_failed", http.StatusTemporaryRedirect)
return
}
http.Error(w, "Authorization code not provided", http.StatusBadRequest)
return
}
if state == "" {
http.Error(w, "State parameter not provided", http.StatusBadRequest)
return
}
// Exchange code for token
token, err := h.oauthService.HandleOAuthCallback(r.Context(), provider, code, state)
if err != nil {
log.Printf("Failed to handle OAuth callback: %v", err)
http.Redirect(w, r, "/login?error=oauth_failed", http.StatusTemporaryRedirect)
return
}
// Get or create user from OAuth provider
user, err := h.userService.GetOrCreateUserFromGoogle(r.Context(), token, h.oauthService.GetGoogleConfig())
if err != nil {
log.Printf("Failed to get or create user: %v", err)
http.Redirect(w, r, "/login?error=user_creation_failed", http.StatusTemporaryRedirect)
return
}
// Create session
if err := h.sessionStore.CreateSession(w, r, user.ID); err != nil {
log.Printf("Failed to create session: %v", err)
http.Error(w, "Failed to create session", http.StatusInternalServerError)
return
}
// Redirect to dashboard
http.Redirect(w, r, "/dashboard", http.StatusTemporaryRedirect)
}
// HandleLogout logs out the user
// POST /logout
func (h *AuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) {
if err := h.sessionStore.DestroySession(w, r); err != nil {
log.Printf("Failed to destroy session: %v", err)
}
// Redirect to login page
http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
}
// HandleLogin displays the login page
// GET /login
func (h *AuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) {
// Check if user is already logged in
if userID, err := h.sessionStore.GetUserID(r); err == nil && userID != "" {
http.Redirect(w, r, "/dashboard", http.StatusTemporaryRedirect)
return
}
// Get error message if any
errorMsg := ""
if errParam := r.URL.Query().Get("error"); errParam != "" {
switch errParam {
case "oauth_failed":
errorMsg = "Authentication failed. Please try again."
case "user_creation_failed":
errorMsg = "Failed to create user account. Please try again."
default:
errorMsg = "An error occurred. Please try again."
}
}
// Render login template
data := map[string]interface{}{
"Error": errorMsg,
"OAuthProviders": []map[string]string{}, // Empty for now, can be extended
}
if err := h.templates.ExecuteTemplate(w, "login.html", data); err != nil {
log.Printf("Failed to render login template: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,159 @@
package handlers
import (
"html/template"
"net/http"
"net/http/httptest"
"testing"
"custom-start-page/internal/auth"
)
// MockSessionStore is a mock implementation of SessionStore for testing
type MockSessionStore struct {
userID string
shouldError bool
}
func (m *MockSessionStore) CreateSession(w http.ResponseWriter, r *http.Request, userID string) error {
m.userID = userID
return nil
}
func (m *MockSessionStore) GetUserID(r *http.Request) (string, error) {
if m.shouldError {
return "", http.ErrNoCookie
}
return m.userID, nil
}
func (m *MockSessionStore) DestroySession(w http.ResponseWriter, r *http.Request) error {
m.userID = ""
return nil
}
func (m *MockSessionStore) ValidateSession(r *http.Request) bool {
return m.userID != ""
}
// createMockTemplate creates a simple mock template for testing
func createMockTemplate() *template.Template {
tmpl := template.New("login.html")
template.Must(tmpl.Parse(`<!DOCTYPE html><html><body>{{if .Error}}<div>{{.Error}}</div>{{end}}<a href="/auth/oauth/google">Login</a></body></html>`))
return tmpl
}
// TestHandleLogin_UnauthenticatedUser tests that unauthenticated users see the login page
func TestHandleLogin_UnauthenticatedUser(t *testing.T) {
// Setup
mockSessionStore := &MockSessionStore{shouldError: true}
oauthService := auth.NewOAuthService("test-client-id", "test-secret", "http://localhost/callback", auth.NewMemoryStateStore())
userService := auth.NewUserService(nil) // nil repo for this test
mockTemplate := createMockTemplate()
handler := NewAuthHandlerWithTemplates(oauthService, userService, mockSessionStore, mockTemplate)
// Create request
req := httptest.NewRequest(http.MethodGet, "/login", nil)
w := httptest.NewRecorder()
// Execute
handler.HandleLogin(w, req)
// Assert
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Check that response contains login page elements
body := w.Body.String()
if body == "" {
t.Error("Expected non-empty response body")
}
}
// TestHandleLogin_AuthenticatedUser tests that authenticated users are redirected to dashboard
func TestHandleLogin_AuthenticatedUser(t *testing.T) {
// Setup
mockSessionStore := &MockSessionStore{userID: "test-user-123"}
oauthService := auth.NewOAuthService("test-client-id", "test-secret", "http://localhost/callback", auth.NewMemoryStateStore())
userService := auth.NewUserService(nil)
mockTemplate := createMockTemplate()
handler := NewAuthHandlerWithTemplates(oauthService, userService, mockSessionStore, mockTemplate)
// Create request
req := httptest.NewRequest(http.MethodGet, "/login", nil)
w := httptest.NewRecorder()
// Execute
handler.HandleLogin(w, req)
// Assert
if w.Code != http.StatusTemporaryRedirect {
t.Errorf("Expected status 307, got %d", w.Code)
}
location := w.Header().Get("Location")
if location != "/dashboard" {
t.Errorf("Expected redirect to /dashboard, got %s", location)
}
}
// TestHandleLogin_WithError tests that error messages are displayed
func TestHandleLogin_WithError(t *testing.T) {
// Setup
mockSessionStore := &MockSessionStore{shouldError: true}
oauthService := auth.NewOAuthService("test-client-id", "test-secret", "http://localhost/callback", auth.NewMemoryStateStore())
userService := auth.NewUserService(nil)
mockTemplate := createMockTemplate()
handler := NewAuthHandlerWithTemplates(oauthService, userService, mockSessionStore, mockTemplate)
// Create request with error parameter
req := httptest.NewRequest(http.MethodGet, "/login?error=oauth_failed", nil)
w := httptest.NewRecorder()
// Execute
handler.HandleLogin(w, req)
// Assert
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Check that response contains error message
body := w.Body.String()
if body == "" {
t.Error("Expected non-empty response body")
}
}
// TestHandleLogout tests that logout destroys session and redirects to login
func TestHandleLogout(t *testing.T) {
// Setup
mockSessionStore := &MockSessionStore{userID: "test-user-123"}
oauthService := auth.NewOAuthService("test-client-id", "test-secret", "http://localhost/callback", auth.NewMemoryStateStore())
userService := auth.NewUserService(nil)
mockTemplate := createMockTemplate()
handler := NewAuthHandlerWithTemplates(oauthService, userService, mockSessionStore, mockTemplate)
// Create request
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
w := httptest.NewRecorder()
// Execute
handler.HandleLogout(w, req)
// Assert
if w.Code != http.StatusTemporaryRedirect {
t.Errorf("Expected status 307, got %d", w.Code)
}
location := w.Header().Get("Location")
if location != "/login" {
t.Errorf("Expected redirect to /login, got %s", location)
}
// Verify session was destroyed
if mockSessionStore.userID != "" {
t.Error("Expected session to be destroyed")
}
}

View File

@@ -0,0 +1,58 @@
package handlers
import (
"html/template"
"log"
"net/http"
"path/filepath"
"custom-start-page/internal/middleware"
)
// DashboardHandler handles dashboard-related HTTP requests
type DashboardHandler struct {
templates *template.Template
}
// NewDashboardHandler creates a new dashboard handler
func NewDashboardHandler() *DashboardHandler {
// Parse templates
templates := template.Must(template.ParseGlob(filepath.Join("templates", "*.html")))
template.Must(templates.ParseGlob(filepath.Join("templates", "layouts", "*.html")))
return &DashboardHandler{
templates: templates,
}
}
// HandleDashboard displays the dashboard page
// GET /dashboard
func (h *DashboardHandler) HandleDashboard(w http.ResponseWriter, r *http.Request) {
// Get user ID from context (set by auth middleware)
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
return
}
// TODO: Fetch user's pages from database
// For now, we'll use mock data
pages := []map[string]interface{}{
{
"ID": "default-page",
"Name": "Home",
"Active": true,
},
}
// Render dashboard template
data := map[string]interface{}{
"UserID": userID,
"Pages": pages,
}
if err := h.templates.ExecuteTemplate(w, "dashboard.html", data); err != nil {
log.Printf("Failed to render dashboard template: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,68 @@
package handlers
import (
"context"
"html/template"
"net/http"
"net/http/httptest"
"testing"
"custom-start-page/internal/middleware"
)
// createMockDashboardTemplate creates a simple mock template for testing
func createMockDashboardTemplate() *template.Template {
tmpl := template.New("dashboard.html")
template.Must(tmpl.Parse(`<!DOCTYPE html><html><body><h1>Dashboard</h1><div>User: {{.UserID}}</div></body></html>`))
return tmpl
}
// TestHandleDashboard_WithAuthenticatedUser tests that authenticated users see the dashboard
func TestHandleDashboard_WithAuthenticatedUser(t *testing.T) {
// Setup
mockTemplate := createMockDashboardTemplate()
handler := &DashboardHandler{
templates: mockTemplate,
}
// Create request with user ID in context
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
ctx := context.WithValue(req.Context(), middleware.GetUserIDContextKey(), "test-user-123")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
// Execute
handler.HandleDashboard(w, req)
// Assert
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Check that response contains dashboard content
body := w.Body.String()
if body == "" {
t.Error("Expected non-empty response body")
}
}
// TestHandleDashboard_WithoutUserID tests that requests without user ID fail
func TestHandleDashboard_WithoutUserID(t *testing.T) {
// Setup
mockTemplate := createMockDashboardTemplate()
handler := &DashboardHandler{
templates: mockTemplate,
}
// Create request without user ID in context
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
w := httptest.NewRecorder()
// Execute
handler.HandleDashboard(w, req)
// Assert
if w.Code != http.StatusInternalServerError {
t.Errorf("Expected status 500, got %d", w.Code)
}
}

View File

@@ -0,0 +1,102 @@
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
"custom-start-page/internal/auth"
"custom-start-page/internal/middleware"
)
// TestRedirectFlow_UnauthenticatedToLogin tests that unauthenticated users are redirected to login
func TestRedirectFlow_UnauthenticatedToLogin(t *testing.T) {
// Setup
mockSessionStore := &MockSessionStore{shouldError: true}
// Create middleware
requireAuth := middleware.RequireAuth(mockSessionStore)
// Create dashboard handler
mockDashboardTemplate := createMockDashboardTemplate()
dashboardHandler := &DashboardHandler{templates: mockDashboardTemplate}
// Wrap dashboard handler with auth middleware
protectedHandler := requireAuth(http.HandlerFunc(dashboardHandler.HandleDashboard))
// Create request to dashboard
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
w := httptest.NewRecorder()
// Execute
protectedHandler.ServeHTTP(w, req)
// Assert - should redirect to login
if w.Code != http.StatusSeeOther {
t.Errorf("Expected status 303, got %d", w.Code)
}
location := w.Header().Get("Location")
if location != "/login" {
t.Errorf("Expected redirect to /login, got %s", location)
}
}
// TestRedirectFlow_AuthenticatedToDashboard tests that authenticated users accessing login are redirected to dashboard
func TestRedirectFlow_AuthenticatedToDashboard(t *testing.T) {
// Setup
mockSessionStore := &MockSessionStore{userID: "test-user-123"}
oauthService := auth.NewOAuthService("test-client-id", "test-secret", "http://localhost/callback", auth.NewMemoryStateStore())
userService := auth.NewUserService(nil)
mockTemplate := createMockTemplate()
authHandler := NewAuthHandlerWithTemplates(oauthService, userService, mockSessionStore, mockTemplate)
// Create request to login page
req := httptest.NewRequest(http.MethodGet, "/login", nil)
w := httptest.NewRecorder()
// Execute
authHandler.HandleLogin(w, req)
// Assert - should redirect to dashboard
if w.Code != http.StatusTemporaryRedirect {
t.Errorf("Expected status 307, got %d", w.Code)
}
location := w.Header().Get("Location")
if location != "/dashboard" {
t.Errorf("Expected redirect to /dashboard, got %s", location)
}
}
// TestRedirectFlow_LogoutToLogin tests that logout redirects to login
func TestRedirectFlow_LogoutToLogin(t *testing.T) {
// Setup
mockSessionStore := &MockSessionStore{userID: "test-user-123"}
oauthService := auth.NewOAuthService("test-client-id", "test-secret", "http://localhost/callback", auth.NewMemoryStateStore())
userService := auth.NewUserService(nil)
mockTemplate := createMockTemplate()
authHandler := NewAuthHandlerWithTemplates(oauthService, userService, mockSessionStore, mockTemplate)
// Create logout request
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
w := httptest.NewRecorder()
// Execute
authHandler.HandleLogout(w, req)
// Assert - should redirect to login
if w.Code != http.StatusTemporaryRedirect {
t.Errorf("Expected status 307, got %d", w.Code)
}
location := w.Header().Get("Location")
if location != "/login" {
t.Errorf("Expected redirect to /login, got %s", location)
}
// Verify session was destroyed
if mockSessionStore.userID != "" {
t.Error("Expected session to be destroyed after logout")
}
}

View File

@@ -0,0 +1,52 @@
package middleware
import (
"context"
"net/http"
)
// SessionStore defines the interface for session validation
type SessionStore interface {
ValidateSession(r *http.Request) bool
GetUserID(r *http.Request) (string, error)
}
// contextKey is a custom type for context keys to avoid collisions
type contextKey string
const userIDContextKey contextKey = "user_id"
// GetUserIDContextKey returns the context key for user ID (for testing)
func GetUserIDContextKey() contextKey {
return userIDContextKey
}
// RequireAuth is a middleware that ensures the user is authenticated
func RequireAuth(sessionStore SessionStore) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Validate session
if !sessionStore.ValidateSession(r) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// Get user ID and add to context
userID, err := sessionStore.GetUserID(r)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// Add user ID to request context
ctx := context.WithValue(r.Context(), userIDContextKey, userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// GetUserIDFromContext retrieves the user ID from the request context
func GetUserIDFromContext(ctx context.Context) (string, bool) {
userID, ok := ctx.Value(userIDContextKey).(string)
return userID, ok
}

View File

@@ -0,0 +1,145 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
)
// mockSessionStore is a mock implementation of SessionStore for testing
type mockSessionStore struct {
valid bool
userID string
}
func (m *mockSessionStore) ValidateSession(r *http.Request) bool {
return m.valid
}
func (m *mockSessionStore) GetUserID(r *http.Request) (string, error) {
if !m.valid {
return "", http.ErrNoCookie
}
return m.userID, nil
}
func TestRequireAuth(t *testing.T) {
t.Run("allows authenticated requests", func(t *testing.T) {
mockStore := &mockSessionStore{
valid: true,
userID: "test-user-123",
}
handler := RequireAuth(mockStore)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID, ok := GetUserIDFromContext(r.Context())
if !ok {
t.Error("Expected user ID in context")
return
}
if userID != "test-user-123" {
t.Errorf("Expected user ID test-user-123, got %s", userID)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("success"))
}))
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/dashboard", nil)
handler.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
if w.Body.String() != "success" {
t.Errorf("Expected body 'success', got %s", w.Body.String())
}
})
t.Run("redirects unauthenticated requests", func(t *testing.T) {
mockStore := &mockSessionStore{
valid: false,
}
handler := RequireAuth(mockStore)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("Handler should not be called for unauthenticated request")
}))
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/dashboard", nil)
handler.ServeHTTP(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("Expected status 303, got %d", w.Code)
}
location := w.Header().Get("Location")
if location != "/login" {
t.Errorf("Expected redirect to /login, got %s", location)
}
})
t.Run("redirects when GetUserID fails", func(t *testing.T) {
mockStore := &mockSessionStore{
valid: true, // ValidateSession returns true
userID: "", // But GetUserID will fail
}
// Override GetUserID to return error
mockStore.valid = false
handler := RequireAuth(mockStore)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("Handler should not be called when GetUserID fails")
}))
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/dashboard", nil)
handler.ServeHTTP(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("Expected status 303, got %d", w.Code)
}
})
}
func TestGetUserIDFromContext(t *testing.T) {
t.Run("retrieves user ID from context", func(t *testing.T) {
mockStore := &mockSessionStore{
valid: true,
userID: "context-user-456",
}
handler := RequireAuth(mockStore)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID, ok := GetUserIDFromContext(r.Context())
if !ok {
t.Error("Expected user ID in context")
return
}
if userID != "context-user-456" {
t.Errorf("Expected user ID context-user-456, got %s", userID)
}
w.WriteHeader(http.StatusOK)
}))
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/test", nil)
handler.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
})
t.Run("returns false when user ID not in context", func(t *testing.T) {
r := httptest.NewRequest("GET", "/test", nil)
_, ok := GetUserIDFromContext(r.Context())
if ok {
t.Error("Expected ok to be false when user ID not in context")
}
})
}

15
internal/models/user.go Normal file
View File

@@ -0,0 +1,15 @@
package models
import (
"time"
)
// User represents a user in the system
type User struct {
ID string `dynamodbav:"user_id" json:"id"`
Email string `dynamodbav:"email" json:"email"`
OAuthProvider string `dynamodbav:"oauth_provider" json:"oauth_provider"`
OAuthID string `dynamodbav:"oauth_id" json:"oauth_id"`
CreatedAt time.Time `dynamodbav:"created_at" json:"created_at"`
UpdatedAt time.Time `dynamodbav:"updated_at" json:"updated_at"`
}

124
internal/storage/README.md Normal file
View File

@@ -0,0 +1,124 @@
# DynamoDB Storage Service
This package provides an enhanced DynamoDB client wrapper with the following features:
## Features
### 1. Connection Pooling
The client uses the AWS SDK's default HTTP client which includes connection pooling automatically. This ensures efficient reuse of TCP connections to DynamoDB.
### 2. Retry Logic with Exponential Backoff
The client is configured with automatic retry logic:
- **Max Attempts**: 5 retries
- **Max Backoff**: 20 seconds
- **Strategy**: Exponential backoff with jitter to prevent thundering herd
This handles transient failures gracefully and improves reliability.
### 3. Transaction Support
The `TransactWriteItems` method provides ACID transaction support for multiple write operations:
```go
err := client.TransactWriteItems(ctx, &dynamodb.TransactWriteItemsInput{
TransactItems: []types.TransactWriteItem{
{
Put: &types.Put{
TableName: aws.String("MyTable"),
Item: map[string]types.AttributeValue{
"id": &types.AttributeValueMemberS{Value: "item1"},
},
},
},
// More items...
},
})
```
### 4. Batch Operations
The client provides batch read and write operations with automatic retry of unprocessed items:
#### BatchGetItems
Retrieves multiple items in a single request:
```go
output, err := client.BatchGetItems(ctx, &dynamodb.BatchGetItemInput{
RequestItems: map[string]types.KeysAndAttributes{
"MyTable": {
Keys: []map[string]types.AttributeValue{
{"id": &types.AttributeValueMemberS{Value: "item1"}},
{"id": &types.AttributeValueMemberS{Value: "item2"}},
},
},
},
})
```
#### BatchWriteItems
Writes multiple items in a single request:
```go
err := client.BatchWriteItems(ctx, &dynamodb.BatchWriteItemInput{
RequestItems: map[string][]types.WriteRequest{
"MyTable": {
{
PutRequest: &types.PutRequest{
Item: map[string]types.AttributeValue{
"id": &types.AttributeValueMemberS{Value: "item1"},
},
},
},
},
},
})
```
Both batch operations automatically handle unprocessed items with exponential backoff retry logic.
## Standard Operations
The client also provides wrapped versions of standard DynamoDB operations with automatic retry:
- `PutItem` - Put a single item
- `GetItem` - Get a single item
- `UpdateItem` - Update a single item
- `DeleteItem` - Delete a single item
- `Query` - Query items
## Usage
### Creating a Client
```go
ctx := context.Background()
client, err := storage.NewDynamoDBClient(ctx, "http://localhost:8000")
if err != nil {
log.Fatal(err)
}
```
For production (AWS DynamoDB), pass an empty string for the endpoint:
```go
client, err := storage.NewDynamoDBClient(ctx, "")
```
### Testing
The package includes comprehensive tests that can be run against DynamoDB Local:
1. Start DynamoDB Local:
```bash
docker-compose up -d
```
2. Run tests:
```bash
go test -v ./internal/storage
```
Tests will automatically skip if DynamoDB is not available.
## Requirements Addressed
This implementation addresses the following requirements from the spec:
- **Requirement 8.1**: Immediate persistence of all changes
- **Requirement 8.8**: Efficient scaling for 10,000+ items per user
- **Design requirement**: Retry logic with exponential backoff
- **Design requirement**: Transaction support for atomic operations
- **Design requirement**: Batch operations for efficient bulk reads/writes
- **Design requirement**: Connection pooling for performance

View File

@@ -0,0 +1,248 @@
package storage
import (
"context"
"fmt"
"math"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/aws/retry"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)
// DynamoDBClient wraps the AWS DynamoDB client with enhanced features
type DynamoDBClient struct {
client *dynamodb.Client
}
// NewDynamoDBClient creates a new DynamoDB client with connection pooling and retry logic
func NewDynamoDBClient(ctx context.Context, endpoint string) (*DynamoDBClient, error) {
// Configure retry strategy with exponential backoff
retryer := retry.NewStandard(func(o *retry.StandardOptions) {
o.MaxAttempts = 5
o.MaxBackoff = 20 * time.Second
// Use exponential backoff with jitter
o.Backoff = retry.NewExponentialJitterBackoff(20 * time.Second)
})
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRetryer(func() aws.Retryer {
return retryer
}),
// Connection pooling is handled by the HTTP client
config.WithHTTPClient(nil), // Uses default HTTP client with connection pooling
)
if err != nil {
return nil, fmt.Errorf("failed to load AWS config: %w", err)
}
// Override endpoint if provided (for local DynamoDB)
if endpoint != "" {
cfg.BaseEndpoint = aws.String(endpoint)
}
client := dynamodb.NewFromConfig(cfg)
return &DynamoDBClient{
client: client,
}, nil
}
// CreateUsersTable creates the Users table in DynamoDB
func (db *DynamoDBClient) CreateUsersTable(ctx context.Context) error {
tableName := "Users"
// Check if table already exists
_, err := db.client.DescribeTable(ctx, &dynamodb.DescribeTableInput{
TableName: aws.String(tableName),
})
if err == nil {
// Table already exists
return nil
}
// Create table
_, err = db.client.CreateTable(ctx, &dynamodb.CreateTableInput{
TableName: aws.String(tableName),
AttributeDefinitions: []types.AttributeDefinition{
{
AttributeName: aws.String("user_id"),
AttributeType: types.ScalarAttributeTypeS,
},
},
KeySchema: []types.KeySchemaElement{
{
AttributeName: aws.String("user_id"),
KeyType: types.KeyTypeHash,
},
},
BillingMode: types.BillingModePayPerRequest,
})
if err != nil {
return fmt.Errorf("failed to create Users table: %w", err)
}
// Wait for table to be active
waiter := dynamodb.NewTableExistsWaiter(db.client)
err = waiter.Wait(ctx, &dynamodb.DescribeTableInput{
TableName: aws.String(tableName),
}, 5*60) // 5 minutes timeout
if err != nil {
return fmt.Errorf("failed waiting for Users table to be active: %w", err)
}
return nil
}
// GetClient returns the underlying DynamoDB client
func (db *DynamoDBClient) GetClient() *dynamodb.Client {
return db.client
}
// TransactWriteItems executes a transactional write operation with automatic retry
func (db *DynamoDBClient) TransactWriteItems(ctx context.Context, input *dynamodb.TransactWriteItemsInput) error {
_, err := db.client.TransactWriteItems(ctx, input)
if err != nil {
return fmt.Errorf("transaction write failed: %w", err)
}
return nil
}
// BatchGetItems retrieves multiple items in a single batch operation
func (db *DynamoDBClient) BatchGetItems(ctx context.Context, input *dynamodb.BatchGetItemInput) (*dynamodb.BatchGetItemOutput, error) {
output, err := db.client.BatchGetItem(ctx, input)
if err != nil {
return nil, fmt.Errorf("batch get failed: %w", err)
}
// Handle unprocessed keys with exponential backoff
if len(output.UnprocessedKeys) > 0 {
return db.retryUnprocessedKeys(ctx, output)
}
return output, nil
}
// BatchWriteItems writes multiple items in a single batch operation
func (db *DynamoDBClient) BatchWriteItems(ctx context.Context, input *dynamodb.BatchWriteItemInput) error {
output, err := db.client.BatchWriteItem(ctx, input)
if err != nil {
return fmt.Errorf("batch write failed: %w", err)
}
// Handle unprocessed items with exponential backoff
if len(output.UnprocessedItems) > 0 {
return db.retryUnprocessedWrites(ctx, output.UnprocessedItems)
}
return nil
}
// retryUnprocessedKeys retries unprocessed keys from BatchGetItem with exponential backoff
func (db *DynamoDBClient) retryUnprocessedKeys(ctx context.Context, output *dynamodb.BatchGetItemOutput) (*dynamodb.BatchGetItemOutput, error) {
maxRetries := 5
backoff := 100 * time.Millisecond
for attempt := 0; attempt < maxRetries && len(output.UnprocessedKeys) > 0; attempt++ {
// Wait with exponential backoff
time.Sleep(backoff)
backoff = time.Duration(math.Min(float64(backoff*2), float64(20*time.Second)))
// Retry unprocessed keys
retryOutput, err := db.client.BatchGetItem(ctx, &dynamodb.BatchGetItemInput{
RequestItems: output.UnprocessedKeys,
})
if err != nil {
return nil, fmt.Errorf("retry batch get failed: %w", err)
}
// Merge responses
for table, items := range retryOutput.Responses {
output.Responses[table] = append(output.Responses[table], items...)
}
output.UnprocessedKeys = retryOutput.UnprocessedKeys
}
if len(output.UnprocessedKeys) > 0 {
return output, fmt.Errorf("failed to process all keys after %d retries", maxRetries)
}
return output, nil
}
// retryUnprocessedWrites retries unprocessed items from BatchWriteItem with exponential backoff
func (db *DynamoDBClient) retryUnprocessedWrites(ctx context.Context, unprocessedItems map[string][]types.WriteRequest) error {
maxRetries := 5
backoff := 100 * time.Millisecond
for attempt := 0; attempt < maxRetries && len(unprocessedItems) > 0; attempt++ {
// Wait with exponential backoff
time.Sleep(backoff)
backoff = time.Duration(math.Min(float64(backoff*2), float64(20*time.Second)))
// Retry unprocessed items
output, err := db.client.BatchWriteItem(ctx, &dynamodb.BatchWriteItemInput{
RequestItems: unprocessedItems,
})
if err != nil {
return fmt.Errorf("retry batch write failed: %w", err)
}
unprocessedItems = output.UnprocessedItems
}
if len(unprocessedItems) > 0 {
return fmt.Errorf("failed to process all items after %d retries", maxRetries)
}
return nil
}
// PutItem puts a single item with automatic retry
func (db *DynamoDBClient) PutItem(ctx context.Context, input *dynamodb.PutItemInput) error {
_, err := db.client.PutItem(ctx, input)
if err != nil {
return fmt.Errorf("put item failed: %w", err)
}
return nil
}
// GetItem retrieves a single item with automatic retry
func (db *DynamoDBClient) GetItem(ctx context.Context, input *dynamodb.GetItemInput) (*dynamodb.GetItemOutput, error) {
output, err := db.client.GetItem(ctx, input)
if err != nil {
return nil, fmt.Errorf("get item failed: %w", err)
}
return output, nil
}
// Query executes a query operation with automatic retry
func (db *DynamoDBClient) Query(ctx context.Context, input *dynamodb.QueryInput) (*dynamodb.QueryOutput, error) {
output, err := db.client.Query(ctx, input)
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
return output, nil
}
// UpdateItem updates a single item with automatic retry
func (db *DynamoDBClient) UpdateItem(ctx context.Context, input *dynamodb.UpdateItemInput) (*dynamodb.UpdateItemOutput, error) {
output, err := db.client.UpdateItem(ctx, input)
if err != nil {
return nil, fmt.Errorf("update item failed: %w", err)
}
return output, nil
}
// DeleteItem deletes a single item with automatic retry
func (db *DynamoDBClient) DeleteItem(ctx context.Context, input *dynamodb.DeleteItemInput) error {
_, err := db.client.DeleteItem(ctx, input)
if err != nil {
return fmt.Errorf("delete item failed: %w", err)
}
return nil
}

View File

@@ -0,0 +1,452 @@
package storage
import (
"context"
"os"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)
func setupTestClient(t *testing.T) (*DynamoDBClient, context.Context) {
t.Helper()
// Set dummy AWS credentials for local DynamoDB
os.Setenv("AWS_ACCESS_KEY_ID", "dummy")
os.Setenv("AWS_SECRET_ACCESS_KEY", "dummy")
os.Setenv("AWS_REGION", "us-east-1")
endpoint := os.Getenv("DYNAMODB_ENDPOINT")
if endpoint == "" {
endpoint = "http://localhost:8000"
}
ctx := context.Background()
client, err := NewDynamoDBClient(ctx, endpoint)
if err != nil {
t.Skipf("Skipping test: DynamoDB not available: %v", err)
}
// Test connection by listing tables
_, err = client.client.ListTables(ctx, &dynamodb.ListTablesInput{})
if err != nil {
t.Skipf("Skipping test: Cannot connect to DynamoDB: %v", err)
}
return client, ctx
}
func TestNewDynamoDBClient(t *testing.T) {
client, _ := setupTestClient(t)
if client == nil {
t.Fatal("Expected non-nil client")
}
if client.client == nil {
t.Fatal("Expected non-nil underlying client")
}
}
func TestTransactWriteItems(t *testing.T) {
client, ctx := setupTestClient(t)
// Create test table
tableName := "TestTransactions"
createTestTable(t, ctx, client, tableName)
defer deleteTestTable(t, ctx, client, tableName)
// Test transaction with two puts
testID1 := "test-txn-1"
testID2 := "test-txn-2"
input := &dynamodb.TransactWriteItemsInput{
TransactItems: []types.TransactWriteItem{
{
Put: &types.Put{
TableName: aws.String(tableName),
Item: map[string]types.AttributeValue{
"id": &types.AttributeValueMemberS{Value: testID1},
"value": &types.AttributeValueMemberS{Value: "value1"},
},
},
},
{
Put: &types.Put{
TableName: aws.String(tableName),
Item: map[string]types.AttributeValue{
"id": &types.AttributeValueMemberS{Value: testID2},
"value": &types.AttributeValueMemberS{Value: "value2"},
},
},
},
},
}
err := client.TransactWriteItems(ctx, input)
if err != nil {
t.Fatalf("TransactWriteItems failed: %v", err)
}
// Verify both items were written
output1, err := client.GetItem(ctx, &dynamodb.GetItemInput{
TableName: aws.String(tableName),
Key: map[string]types.AttributeValue{
"id": &types.AttributeValueMemberS{Value: testID1},
},
})
if err != nil {
t.Fatalf("GetItem failed: %v", err)
}
if len(output1.Item) == 0 {
t.Fatal("Expected item to exist after transaction")
}
output2, err := client.GetItem(ctx, &dynamodb.GetItemInput{
TableName: aws.String(tableName),
Key: map[string]types.AttributeValue{
"id": &types.AttributeValueMemberS{Value: testID2},
},
})
if err != nil {
t.Fatalf("GetItem failed: %v", err)
}
if len(output2.Item) == 0 {
t.Fatal("Expected item to exist after transaction")
}
}
func TestBatchGetItems(t *testing.T) {
client, ctx := setupTestClient(t)
// Create test table
tableName := "TestBatchGet"
createTestTable(t, ctx, client, tableName)
defer deleteTestTable(t, ctx, client, tableName)
// Put test items
testIDs := []string{"batch-1", "batch-2", "batch-3"}
for _, id := range testIDs {
err := client.PutItem(ctx, &dynamodb.PutItemInput{
TableName: aws.String(tableName),
Item: map[string]types.AttributeValue{
"id": &types.AttributeValueMemberS{Value: id},
"value": &types.AttributeValueMemberS{Value: "test-value"},
},
})
if err != nil {
t.Fatalf("PutItem failed: %v", err)
}
}
// Batch get items
keys := make([]map[string]types.AttributeValue, len(testIDs))
for i, id := range testIDs {
keys[i] = map[string]types.AttributeValue{
"id": &types.AttributeValueMemberS{Value: id},
}
}
output, err := client.BatchGetItems(ctx, &dynamodb.BatchGetItemInput{
RequestItems: map[string]types.KeysAndAttributes{
tableName: {
Keys: keys,
},
},
})
if err != nil {
t.Fatalf("BatchGetItems failed: %v", err)
}
if len(output.Responses[tableName]) != len(testIDs) {
t.Fatalf("Expected %d items, got %d", len(testIDs), len(output.Responses[tableName]))
}
}
func TestBatchWriteItems(t *testing.T) {
client, ctx := setupTestClient(t)
// Create test table
tableName := "TestBatchWrite"
createTestTable(t, ctx, client, tableName)
defer deleteTestTable(t, ctx, client, tableName)
// Batch write items
testIDs := []string{"write-1", "write-2", "write-3"}
writeRequests := make([]types.WriteRequest, len(testIDs))
for i, id := range testIDs {
writeRequests[i] = types.WriteRequest{
PutRequest: &types.PutRequest{
Item: map[string]types.AttributeValue{
"id": &types.AttributeValueMemberS{Value: id},
"value": &types.AttributeValueMemberS{Value: "batch-value"},
},
},
}
}
err := client.BatchWriteItems(ctx, &dynamodb.BatchWriteItemInput{
RequestItems: map[string][]types.WriteRequest{
tableName: writeRequests,
},
})
if err != nil {
t.Fatalf("BatchWriteItems failed: %v", err)
}
// Verify items were written
for _, id := range testIDs {
output, err := client.GetItem(ctx, &dynamodb.GetItemInput{
TableName: aws.String(tableName),
Key: map[string]types.AttributeValue{
"id": &types.AttributeValueMemberS{Value: id},
},
})
if err != nil {
t.Fatalf("GetItem failed: %v", err)
}
if len(output.Item) == 0 {
t.Fatalf("Expected item %s to exist after batch write", id)
}
}
}
func TestPutAndGetItem(t *testing.T) {
client, ctx := setupTestClient(t)
// Create test table
tableName := "TestPutGet"
createTestTable(t, ctx, client, tableName)
defer deleteTestTable(t, ctx, client, tableName)
// Put item
testID := "put-get-test"
err := client.PutItem(ctx, &dynamodb.PutItemInput{
TableName: aws.String(tableName),
Item: map[string]types.AttributeValue{
"id": &types.AttributeValueMemberS{Value: testID},
"value": &types.AttributeValueMemberS{Value: "test-value"},
},
})
if err != nil {
t.Fatalf("PutItem failed: %v", err)
}
// Get item
output, err := client.GetItem(ctx, &dynamodb.GetItemInput{
TableName: aws.String(tableName),
Key: map[string]types.AttributeValue{
"id": &types.AttributeValueMemberS{Value: testID},
},
})
if err != nil {
t.Fatalf("GetItem failed: %v", err)
}
if len(output.Item) == 0 {
t.Fatal("Expected item to exist")
}
valueAttr, ok := output.Item["value"]
if !ok {
t.Fatal("Expected 'value' attribute")
}
value := valueAttr.(*types.AttributeValueMemberS).Value
if value != "test-value" {
t.Fatalf("Expected value 'test-value', got '%s'", value)
}
}
func TestUpdateItem(t *testing.T) {
client, ctx := setupTestClient(t)
// Create test table
tableName := "TestUpdate"
createTestTable(t, ctx, client, tableName)
defer deleteTestTable(t, ctx, client, tableName)
// Put initial item
testID := "update-test"
err := client.PutItem(ctx, &dynamodb.PutItemInput{
TableName: aws.String(tableName),
Item: map[string]types.AttributeValue{
"id": &types.AttributeValueMemberS{Value: testID},
"value": &types.AttributeValueMemberS{Value: "initial"},
},
})
if err != nil {
t.Fatalf("PutItem failed: %v", err)
}
// Update item
_, err = client.UpdateItem(ctx, &dynamodb.UpdateItemInput{
TableName: aws.String(tableName),
Key: map[string]types.AttributeValue{
"id": &types.AttributeValueMemberS{Value: testID},
},
UpdateExpression: aws.String("SET #v = :val"),
ExpressionAttributeNames: map[string]string{
"#v": "value",
},
ExpressionAttributeValues: map[string]types.AttributeValue{
":val": &types.AttributeValueMemberS{Value: "updated"},
},
})
if err != nil {
t.Fatalf("UpdateItem failed: %v", err)
}
// Verify update
output, err := client.GetItem(ctx, &dynamodb.GetItemInput{
TableName: aws.String(tableName),
Key: map[string]types.AttributeValue{
"id": &types.AttributeValueMemberS{Value: testID},
},
})
if err != nil {
t.Fatalf("GetItem failed: %v", err)
}
value := output.Item["value"].(*types.AttributeValueMemberS).Value
if value != "updated" {
t.Fatalf("Expected value 'updated', got '%s'", value)
}
}
func TestDeleteItem(t *testing.T) {
client, ctx := setupTestClient(t)
// Create test table
tableName := "TestDelete"
createTestTable(t, ctx, client, tableName)
defer deleteTestTable(t, ctx, client, tableName)
// Put item
testID := "delete-test"
err := client.PutItem(ctx, &dynamodb.PutItemInput{
TableName: aws.String(tableName),
Item: map[string]types.AttributeValue{
"id": &types.AttributeValueMemberS{Value: testID},
"value": &types.AttributeValueMemberS{Value: "test"},
},
})
if err != nil {
t.Fatalf("PutItem failed: %v", err)
}
// Delete item
err = client.DeleteItem(ctx, &dynamodb.DeleteItemInput{
TableName: aws.String(tableName),
Key: map[string]types.AttributeValue{
"id": &types.AttributeValueMemberS{Value: testID},
},
})
if err != nil {
t.Fatalf("DeleteItem failed: %v", err)
}
// Verify deletion
output, err := client.GetItem(ctx, &dynamodb.GetItemInput{
TableName: aws.String(tableName),
Key: map[string]types.AttributeValue{
"id": &types.AttributeValueMemberS{Value: testID},
},
})
if err != nil {
t.Fatalf("GetItem failed: %v", err)
}
if len(output.Item) != 0 {
t.Fatal("Expected item to be deleted")
}
}
func TestQuery(t *testing.T) {
client, ctx := setupTestClient(t)
// Create test table
tableName := "TestQuery"
createTestTable(t, ctx, client, tableName)
defer deleteTestTable(t, ctx, client, tableName)
// Put test items
testIDs := []string{"query-1", "query-2", "query-3"}
for _, id := range testIDs {
err := client.PutItem(ctx, &dynamodb.PutItemInput{
TableName: aws.String(tableName),
Item: map[string]types.AttributeValue{
"id": &types.AttributeValueMemberS{Value: id},
"value": &types.AttributeValueMemberS{Value: "test"},
},
})
if err != nil {
t.Fatalf("PutItem failed: %v", err)
}
}
// Query for specific item
output, err := client.Query(ctx, &dynamodb.QueryInput{
TableName: aws.String(tableName),
KeyConditionExpression: aws.String("id = :id"),
ExpressionAttributeValues: map[string]types.AttributeValue{
":id": &types.AttributeValueMemberS{Value: "query-1"},
},
})
if err != nil {
t.Fatalf("Query failed: %v", err)
}
if len(output.Items) != 1 {
t.Fatalf("Expected 1 item, got %d", len(output.Items))
}
}
// Helper functions
func createTestTable(t *testing.T, ctx context.Context, client *DynamoDBClient, tableName string) {
t.Helper()
_, err := client.client.CreateTable(ctx, &dynamodb.CreateTableInput{
TableName: aws.String(tableName),
AttributeDefinitions: []types.AttributeDefinition{
{
AttributeName: aws.String("id"),
AttributeType: types.ScalarAttributeTypeS,
},
},
KeySchema: []types.KeySchemaElement{
{
AttributeName: aws.String("id"),
KeyType: types.KeyTypeHash,
},
},
BillingMode: types.BillingModePayPerRequest,
})
if err != nil {
t.Fatalf("Failed to create test table: %v", err)
}
// Wait for table to be active
waiter := dynamodb.NewTableExistsWaiter(client.client)
err = waiter.Wait(ctx, &dynamodb.DescribeTableInput{
TableName: aws.String(tableName),
}, 30*time.Second)
if err != nil {
t.Fatalf("Failed waiting for table to be active: %v", err)
}
}
func deleteTestTable(t *testing.T, ctx context.Context, client *DynamoDBClient, tableName string) {
t.Helper()
_, err := client.client.DeleteTable(ctx, &dynamodb.DeleteTableInput{
TableName: aws.String(tableName),
})
if err != nil {
t.Logf("Warning: Failed to delete test table: %v", err)
}
}

View File

@@ -0,0 +1,115 @@
package storage
import (
"context"
"fmt"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"custom-start-page/internal/models"
)
// UserRepository handles user data operations
type UserRepository struct {
client *DynamoDBClient
tableName string
}
// NewUserRepository creates a new user repository
func NewUserRepository(client *DynamoDBClient, tableName string) *UserRepository {
return &UserRepository{
client: client,
tableName: tableName,
}
}
// Create creates a new user in the database
func (r *UserRepository) Create(ctx context.Context, user *models.User) error {
item, err := attributevalue.MarshalMap(user)
if err != nil {
return fmt.Errorf("failed to marshal user: %w", err)
}
_, err = r.client.GetClient().PutItem(ctx, &dynamodb.PutItemInput{
TableName: aws.String(r.tableName),
Item: item,
})
if err != nil {
return fmt.Errorf("failed to create user: %w", err)
}
return nil
}
// GetByID retrieves a user by their ID
func (r *UserRepository) GetByID(ctx context.Context, userID string) (*models.User, error) {
result, err := r.client.GetClient().GetItem(ctx, &dynamodb.GetItemInput{
TableName: aws.String(r.tableName),
Key: map[string]types.AttributeValue{
"user_id": &types.AttributeValueMemberS{Value: userID},
},
})
if err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}
if result.Item == nil {
return nil, fmt.Errorf("user not found")
}
var user models.User
if err := attributevalue.UnmarshalMap(result.Item, &user); err != nil {
return nil, fmt.Errorf("failed to unmarshal user: %w", err)
}
return &user, nil
}
// GetByOAuthID retrieves a user by their OAuth provider and OAuth ID
func (r *UserRepository) GetByOAuthID(ctx context.Context, provider, oauthID string) (*models.User, error) {
// Use a scan with filter for now
// In production, consider adding a GSI on oauth_provider + oauth_id
result, err := r.client.GetClient().Scan(ctx, &dynamodb.ScanInput{
TableName: aws.String(r.tableName),
FilterExpression: aws.String("oauth_provider = :provider AND oauth_id = :oauth_id"),
ExpressionAttributeValues: map[string]types.AttributeValue{
":provider": &types.AttributeValueMemberS{Value: provider},
":oauth_id": &types.AttributeValueMemberS{Value: oauthID},
},
})
if err != nil {
return nil, fmt.Errorf("failed to scan users: %w", err)
}
if len(result.Items) == 0 {
return nil, fmt.Errorf("user not found")
}
var user models.User
if err := attributevalue.UnmarshalMap(result.Items[0], &user); err != nil {
return nil, fmt.Errorf("failed to unmarshal user: %w", err)
}
return &user, nil
}
// Update updates an existing user
func (r *UserRepository) Update(ctx context.Context, user *models.User) error {
item, err := attributevalue.MarshalMap(user)
if err != nil {
return fmt.Errorf("failed to marshal user: %w", err)
}
_, err = r.client.GetClient().PutItem(ctx, &dynamodb.PutItemInput{
TableName: aws.String(r.tableName),
Item: item,
})
if err != nil {
return fmt.Errorf("failed to update user: %w", err)
}
return nil
}

View File

@@ -0,0 +1,143 @@
package storage
import (
"context"
"fmt"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/google/uuid"
"custom-start-page/internal/models"
)
const usersTableName = "Users"
// UserStorage handles user data operations
type UserStorage struct {
db *DynamoDBClient
}
// NewUserStorage creates a new UserStorage instance
func NewUserStorage(db *DynamoDBClient) *UserStorage {
return &UserStorage{db: db}
}
// CreateUser creates a new user in DynamoDB
func (s *UserStorage) CreateUser(ctx context.Context, email, oauthProvider, oauthID string) (*models.User, error) {
user := &models.User{
ID: uuid.New().String(),
Email: email,
OAuthProvider: oauthProvider,
OAuthID: oauthID,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
item, err := attributevalue.MarshalMap(user)
if err != nil {
return nil, fmt.Errorf("failed to marshal user: %w", err)
}
_, err = s.db.client.PutItem(ctx, &dynamodb.PutItemInput{
TableName: aws.String(usersTableName),
Item: item,
})
if err != nil {
return nil, fmt.Errorf("failed to create user: %w", err)
}
return user, nil
}
// GetUserByID retrieves a user by their ID
func (s *UserStorage) GetUserByID(ctx context.Context, userID string) (*models.User, error) {
result, err := s.db.client.GetItem(ctx, &dynamodb.GetItemInput{
TableName: aws.String(usersTableName),
Key: map[string]types.AttributeValue{
"user_id": &types.AttributeValueMemberS{Value: userID},
},
})
if err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}
if result.Item == nil {
return nil, fmt.Errorf("user not found")
}
var user models.User
err = attributevalue.UnmarshalMap(result.Item, &user)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal user: %w", err)
}
return &user, nil
}
// GetUserByOAuth retrieves a user by their OAuth provider and ID
func (s *UserStorage) GetUserByOAuth(ctx context.Context, oauthProvider, oauthID string) (*models.User, error) {
// Since we don't have a GSI for oauth_provider + oauth_id, we need to scan
// In production, you might want to add a GSI for this access pattern
result, err := s.db.client.Scan(ctx, &dynamodb.ScanInput{
TableName: aws.String(usersTableName),
FilterExpression: aws.String("oauth_provider = :provider AND oauth_id = :id"),
ExpressionAttributeValues: map[string]types.AttributeValue{
":provider": &types.AttributeValueMemberS{Value: oauthProvider},
":id": &types.AttributeValueMemberS{Value: oauthID},
},
})
if err != nil {
return nil, fmt.Errorf("failed to scan for user: %w", err)
}
if len(result.Items) == 0 {
return nil, fmt.Errorf("user not found")
}
var user models.User
err = attributevalue.UnmarshalMap(result.Items[0], &user)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal user: %w", err)
}
return &user, nil
}
// UpdateUser updates an existing user
func (s *UserStorage) UpdateUser(ctx context.Context, user *models.User) error {
user.UpdatedAt = time.Now()
item, err := attributevalue.MarshalMap(user)
if err != nil {
return fmt.Errorf("failed to marshal user: %w", err)
}
_, err = s.db.client.PutItem(ctx, &dynamodb.PutItemInput{
TableName: aws.String(usersTableName),
Item: item,
})
if err != nil {
return fmt.Errorf("failed to update user: %w", err)
}
return nil
}
// DeleteUser deletes a user by their ID
func (s *UserStorage) DeleteUser(ctx context.Context, userID string) error {
_, err := s.db.client.DeleteItem(ctx, &dynamodb.DeleteItemInput{
TableName: aws.String(usersTableName),
Key: map[string]types.AttributeValue{
"user_id": &types.AttributeValueMemberS{Value: userID},
},
})
if err != nil {
return fmt.Errorf("failed to delete user: %w", err)
}
return nil
}

View File

@@ -0,0 +1,228 @@
package storage
import (
"context"
"os"
"testing"
"time"
)
func setupTestDB(t *testing.T) *DynamoDBClient {
ctx := context.Background()
endpoint := os.Getenv("DYNAMODB_ENDPOINT")
if endpoint == "" {
endpoint = "http://localhost:8000"
}
db, err := NewDynamoDBClient(ctx, endpoint)
if err != nil {
t.Fatalf("Failed to create DynamoDB client: %v", err)
}
// Create Users table
if err := db.CreateUsersTable(ctx); err != nil {
t.Fatalf("Failed to create Users table: %v", err)
}
return db
}
func TestCreateUser(t *testing.T) {
db := setupTestDB(t)
storage := NewUserStorage(db)
ctx := context.Background()
user, err := storage.CreateUser(ctx, "test@example.com", "google", "google123")
if err != nil {
t.Fatalf("Failed to create user: %v", err)
}
if user.ID == "" {
t.Error("User ID should not be empty")
}
if user.Email != "test@example.com" {
t.Errorf("Expected email 'test@example.com', got '%s'", user.Email)
}
if user.OAuthProvider != "google" {
t.Errorf("Expected oauth_provider 'google', got '%s'", user.OAuthProvider)
}
if user.OAuthID != "google123" {
t.Errorf("Expected oauth_id 'google123', got '%s'", user.OAuthID)
}
if user.CreatedAt.IsZero() {
t.Error("CreatedAt should not be zero")
}
if user.UpdatedAt.IsZero() {
t.Error("UpdatedAt should not be zero")
}
}
func TestGetUserByID(t *testing.T) {
db := setupTestDB(t)
storage := NewUserStorage(db)
ctx := context.Background()
// Create a user first
createdUser, err := storage.CreateUser(ctx, "test@example.com", "google", "google123")
if err != nil {
t.Fatalf("Failed to create user: %v", err)
}
// Retrieve the user
retrievedUser, err := storage.GetUserByID(ctx, createdUser.ID)
if err != nil {
t.Fatalf("Failed to get user: %v", err)
}
if retrievedUser.ID != createdUser.ID {
t.Errorf("Expected ID '%s', got '%s'", createdUser.ID, retrievedUser.ID)
}
if retrievedUser.Email != createdUser.Email {
t.Errorf("Expected email '%s', got '%s'", createdUser.Email, retrievedUser.Email)
}
}
func TestGetUserByID_NotFound(t *testing.T) {
db := setupTestDB(t)
storage := NewUserStorage(db)
ctx := context.Background()
_, err := storage.GetUserByID(ctx, "nonexistent-id")
if err == nil {
t.Error("Expected error when getting nonexistent user")
}
}
func TestGetUserByOAuth(t *testing.T) {
db := setupTestDB(t)
storage := NewUserStorage(db)
ctx := context.Background()
// Create a user first
createdUser, err := storage.CreateUser(ctx, "test@example.com", "google", "google123")
if err != nil {
t.Fatalf("Failed to create user: %v", err)
}
// Retrieve the user by OAuth
retrievedUser, err := storage.GetUserByOAuth(ctx, "google", "google123")
if err != nil {
t.Fatalf("Failed to get user by OAuth: %v", err)
}
if retrievedUser.ID != createdUser.ID {
t.Errorf("Expected ID '%s', got '%s'", createdUser.ID, retrievedUser.ID)
}
if retrievedUser.Email != createdUser.Email {
t.Errorf("Expected email '%s', got '%s'", createdUser.Email, retrievedUser.Email)
}
}
func TestGetUserByOAuth_NotFound(t *testing.T) {
db := setupTestDB(t)
storage := NewUserStorage(db)
ctx := context.Background()
_, err := storage.GetUserByOAuth(ctx, "google", "nonexistent")
if err == nil {
t.Error("Expected error when getting nonexistent user")
}
}
func TestUpdateUser(t *testing.T) {
db := setupTestDB(t)
storage := NewUserStorage(db)
ctx := context.Background()
// Create a user first
user, err := storage.CreateUser(ctx, "test@example.com", "google", "google123")
if err != nil {
t.Fatalf("Failed to create user: %v", err)
}
originalUpdatedAt := user.UpdatedAt
time.Sleep(10 * time.Millisecond) // Ensure time difference
// Update the user
user.Email = "updated@example.com"
err = storage.UpdateUser(ctx, user)
if err != nil {
t.Fatalf("Failed to update user: %v", err)
}
// Retrieve and verify
updatedUser, err := storage.GetUserByID(ctx, user.ID)
if err != nil {
t.Fatalf("Failed to get updated user: %v", err)
}
if updatedUser.Email != "updated@example.com" {
t.Errorf("Expected email 'updated@example.com', got '%s'", updatedUser.Email)
}
if !updatedUser.UpdatedAt.After(originalUpdatedAt) {
t.Error("UpdatedAt should be updated")
}
}
func TestDeleteUser(t *testing.T) {
db := setupTestDB(t)
storage := NewUserStorage(db)
ctx := context.Background()
// Create a user first
user, err := storage.CreateUser(ctx, "test@example.com", "google", "google123")
if err != nil {
t.Fatalf("Failed to create user: %v", err)
}
// Delete the user
err = storage.DeleteUser(ctx, user.ID)
if err != nil {
t.Fatalf("Failed to delete user: %v", err)
}
// Verify deletion
_, err = storage.GetUserByID(ctx, user.ID)
if err == nil {
t.Error("Expected error when getting deleted user")
}
}
func TestCreateUser_MultipleUsers(t *testing.T) {
db := setupTestDB(t)
storage := NewUserStorage(db)
ctx := context.Background()
// Create multiple users
user1, err := storage.CreateUser(ctx, "user1@example.com", "google", "google1")
if err != nil {
t.Fatalf("Failed to create user1: %v", err)
}
user2, err := storage.CreateUser(ctx, "user2@example.com", "github", "github1")
if err != nil {
t.Fatalf("Failed to create user2: %v", err)
}
// Verify both users exist and are different
if user1.ID == user2.ID {
t.Error("User IDs should be unique")
}
retrievedUser1, err := storage.GetUserByID(ctx, user1.ID)
if err != nil {
t.Fatalf("Failed to get user1: %v", err)
}
retrievedUser2, err := storage.GetUserByID(ctx, user2.ID)
if err != nil {
t.Fatalf("Failed to get user2: %v", err)
}
if retrievedUser1.Email != "user1@example.com" {
t.Errorf("Expected user1 email 'user1@example.com', got '%s'", retrievedUser1.Email)
}
if retrievedUser2.Email != "user2@example.com" {
t.Errorf("Expected user2 email 'user2@example.com', got '%s'", retrievedUser2.Email)
}
}

View File

@@ -0,0 +1,43 @@
package testing
import (
"testing"
"github.com/leanovate/gopter"
)
// PropertyTestConfig holds configuration for property-based tests
type PropertyTestConfig struct {
MinSuccessfulTests int
MaxSize int
Workers int
}
// DefaultPropertyTestConfig returns default configuration for property tests
func DefaultPropertyTestConfig() *PropertyTestConfig {
return &PropertyTestConfig{
MinSuccessfulTests: 100,
MaxSize: 100,
Workers: 4,
}
}
// RunPropertyTest runs a property-based test with the given configuration
func RunPropertyTest(t *testing.T, config *PropertyTestConfig, testFunc func(*gopter.Properties)) {
if config == nil {
config = DefaultPropertyTestConfig()
}
parameters := gopter.DefaultTestParameters()
parameters.MinSuccessfulTests = config.MinSuccessfulTests
parameters.MaxSize = config.MaxSize
parameters.Workers = config.Workers
properties := gopter.NewProperties(parameters)
// Call the test function to add properties
testFunc(properties)
// Run the tests
properties.TestingRun(t)
}

View File

@@ -0,0 +1,55 @@
package testing
import (
"testing"
"github.com/leanovate/gopter"
"github.com/leanovate/gopter/gen"
"github.com/leanovate/gopter/prop"
)
func TestDefaultPropertyTestConfig(t *testing.T) {
cfg := DefaultPropertyTestConfig()
if cfg.MinSuccessfulTests != 100 {
t.Errorf("Expected MinSuccessfulTests to be 100, got %d", cfg.MinSuccessfulTests)
}
if cfg.MaxSize != 100 {
t.Errorf("Expected MaxSize to be 100, got %d", cfg.MaxSize)
}
if cfg.Workers != 4 {
t.Errorf("Expected Workers to be 4, got %d", cfg.Workers)
}
}
func TestRunPropertyTest(t *testing.T) {
// Simple property: for all integers, x + 0 = x
RunPropertyTest(t, nil, func(properties *gopter.Properties) {
properties.Property("addition identity", prop.ForAll(
func(x int) bool {
return x+0 == x
},
gen.Int(),
))
})
}
func TestRunPropertyTestWithCustomConfig(t *testing.T) {
cfg := &PropertyTestConfig{
MinSuccessfulTests: 50,
MaxSize: 50,
Workers: 2,
}
// Simple property: for all strings, len(s) >= 0
RunPropertyTest(t, cfg, func(properties *gopter.Properties) {
properties.Property("string length non-negative", prop.ForAll(
func(s string) bool {
return len(s) >= 0
},
gen.AnyString(),
))
})
}

114
pkg/config/config.go Normal file
View File

@@ -0,0 +1,114 @@
package config
import (
"fmt"
"os"
"strconv"
)
// Config holds all application configuration
type Config struct {
Server ServerConfig
Database DatabaseConfig
OAuth OAuthConfig
Session SessionConfig
}
// ServerConfig holds server-related configuration
type ServerConfig struct {
Port string
Host string
}
// DatabaseConfig holds DynamoDB configuration
type DatabaseConfig struct {
Region string
Endpoint string // For DynamoDB local
TablePrefix string
UseLocalDB bool
}
// OAuthConfig holds OAuth provider configurations
type OAuthConfig struct {
Google GoogleOAuthConfig
}
// GoogleOAuthConfig holds Google OAuth configuration
type GoogleOAuthConfig struct {
ClientID string
ClientSecret string
RedirectURL string
}
// SessionConfig holds session management configuration
type SessionConfig struct {
SecretKey string
MaxAge int // in seconds
}
// Load loads configuration from environment variables
func Load() (*Config, error) {
cfg := &Config{
Server: ServerConfig{
Port: getEnv("PORT", "8080"),
Host: getEnv("HOST", "localhost"),
},
Database: DatabaseConfig{
Region: getEnv("AWS_REGION", "us-east-1"),
Endpoint: getEnv("DYNAMODB_ENDPOINT", "http://localhost:8000"),
TablePrefix: getEnv("TABLE_PREFIX", "startpage_"),
UseLocalDB: getEnvBool("USE_LOCAL_DB", true),
},
OAuth: OAuthConfig{
Google: GoogleOAuthConfig{
ClientID: getEnv("GOOGLE_CLIENT_ID", ""),
ClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""),
RedirectURL: getEnv("GOOGLE_REDIRECT_URL", "http://localhost:8080/auth/callback/google"),
},
},
Session: SessionConfig{
SecretKey: getEnv("SESSION_SECRET", "change-me-in-production"),
MaxAge: getEnvInt("SESSION_MAX_AGE", 86400*7), // 7 days default
},
}
// Validate required fields
if cfg.OAuth.Google.ClientID == "" {
return nil, fmt.Errorf("GOOGLE_CLIENT_ID is required")
}
if cfg.OAuth.Google.ClientSecret == "" {
return nil, fmt.Errorf("GOOGLE_CLIENT_SECRET is required")
}
return cfg, nil
}
// getEnv gets an environment variable or returns a default value
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// getEnvBool gets a boolean environment variable or returns a default value
func getEnvBool(key string, defaultValue bool) bool {
if value := os.Getenv(key); value != "" {
boolVal, err := strconv.ParseBool(value)
if err == nil {
return boolVal
}
}
return defaultValue
}
// getEnvInt gets an integer environment variable or returns a default value
func getEnvInt(key string, defaultValue int) int {
if value := os.Getenv(key); value != "" {
intVal, err := strconv.Atoi(value)
if err == nil {
return intVal
}
}
return defaultValue
}

191
pkg/config/config_test.go Normal file
View File

@@ -0,0 +1,191 @@
package config
import (
"os"
"testing"
)
func TestLoad(t *testing.T) {
// Set required environment variables
os.Setenv("GOOGLE_CLIENT_ID", "test-client-id")
os.Setenv("GOOGLE_CLIENT_SECRET", "test-client-secret")
defer func() {
os.Unsetenv("GOOGLE_CLIENT_ID")
os.Unsetenv("GOOGLE_CLIENT_SECRET")
}()
cfg, err := Load()
if err != nil {
t.Fatalf("Load() failed: %v", err)
}
// Test default values
if cfg.Server.Port != "8080" {
t.Errorf("Expected default port 8080, got %s", cfg.Server.Port)
}
if cfg.Server.Host != "localhost" {
t.Errorf("Expected default host localhost, got %s", cfg.Server.Host)
}
if cfg.Database.Region != "us-east-1" {
t.Errorf("Expected default region us-east-1, got %s", cfg.Database.Region)
}
if cfg.OAuth.Google.ClientID != "test-client-id" {
t.Errorf("Expected client ID test-client-id, got %s", cfg.OAuth.Google.ClientID)
}
}
func TestLoadMissingOAuthCredentials(t *testing.T) {
// Ensure OAuth credentials are not set
os.Unsetenv("GOOGLE_CLIENT_ID")
os.Unsetenv("GOOGLE_CLIENT_SECRET")
_, err := Load()
if err == nil {
t.Error("Expected error when OAuth credentials are missing, got nil")
}
}
func TestGetEnv(t *testing.T) {
tests := []struct {
name string
key string
defaultValue string
envValue string
expected string
}{
{
name: "returns environment variable when set",
key: "TEST_KEY",
defaultValue: "default",
envValue: "custom",
expected: "custom",
},
{
name: "returns default when environment variable not set",
key: "TEST_KEY_NOT_SET",
defaultValue: "default",
envValue: "",
expected: "default",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.envValue != "" {
os.Setenv(tt.key, tt.envValue)
defer os.Unsetenv(tt.key)
}
result := getEnv(tt.key, tt.defaultValue)
if result != tt.expected {
t.Errorf("Expected %s, got %s", tt.expected, result)
}
})
}
}
func TestGetEnvBool(t *testing.T) {
tests := []struct {
name string
key string
defaultValue bool
envValue string
expected bool
}{
{
name: "returns true when set to true",
key: "TEST_BOOL",
defaultValue: false,
envValue: "true",
expected: true,
},
{
name: "returns false when set to false",
key: "TEST_BOOL",
defaultValue: true,
envValue: "false",
expected: false,
},
{
name: "returns default when not set",
key: "TEST_BOOL_NOT_SET",
defaultValue: true,
envValue: "",
expected: true,
},
{
name: "returns default when invalid value",
key: "TEST_BOOL",
defaultValue: true,
envValue: "invalid",
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.envValue != "" {
os.Setenv(tt.key, tt.envValue)
defer os.Unsetenv(tt.key)
} else {
os.Unsetenv(tt.key)
}
result := getEnvBool(tt.key, tt.defaultValue)
if result != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, result)
}
})
}
}
func TestGetEnvInt(t *testing.T) {
tests := []struct {
name string
key string
defaultValue int
envValue string
expected int
}{
{
name: "returns integer when valid",
key: "TEST_INT",
defaultValue: 100,
envValue: "200",
expected: 200,
},
{
name: "returns default when not set",
key: "TEST_INT_NOT_SET",
defaultValue: 100,
envValue: "",
expected: 100,
},
{
name: "returns default when invalid value",
key: "TEST_INT",
defaultValue: 100,
envValue: "invalid",
expected: 100,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.envValue != "" {
os.Setenv(tt.key, tt.envValue)
defer os.Unsetenv(tt.key)
} else {
os.Unsetenv(tt.key)
}
result := getEnvInt(tt.key, tt.defaultValue)
if result != tt.expected {
t.Errorf("Expected %d, got %d", tt.expected, result)
}
})
}
}

35
static/css/main.css Normal file
View File

@@ -0,0 +1,35 @@
/* Custom styles for Custom Start Page */
/* Ensure smooth transitions */
* {
transition: all 0.2s ease-in-out;
}
/* Widget styles */
.widget {
background: white;
border-radius: 0.5rem;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
padding: 1rem;
}
.widget:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.widget-handle {
cursor: move;
}
/* Loading indicator */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline-block;
}
.htmx-request.htmx-indicator {
display: inline-block;
}

16
static/js/main.js Normal file
View File

@@ -0,0 +1,16 @@
// Custom JavaScript for Custom Start Page
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
console.log('Custom Start Page loaded');
});
// Handle HTMX events
document.body.addEventListener('htmx:afterSwap', function(evt) {
console.log('HTMX swap completed:', evt.detail.target.id);
});
document.body.addEventListener('htmx:responseError', function(evt) {
console.error('HTMX error:', evt.detail);
alert('An error occurred. Please try again.');
});

99
templates/dashboard.html Normal file
View File

@@ -0,0 +1,99 @@
{{template "layouts/base.html" .}}
{{define "title"}}Dashboard - Custom Start Page{{end}}
{{define "content"}}
<div class="min-h-screen flex flex-col">
<!-- Top Toolbar -->
<header class="bg-white shadow-sm border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Logo -->
<div class="flex-shrink-0">
<h1 class="text-xl font-bold text-gray-800">Start Page</h1>
</div>
<!-- Search Bar -->
<div class="flex-1 max-w-2xl mx-8">
<form action="/search" method="get" class="relative">
<input type="text"
name="q"
placeholder="Search the web..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<button type="submit" class="absolute right-3 top-2.5 text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</button>
</form>
</div>
<!-- User Menu -->
<div class="flex items-center space-x-4">
<button class="text-gray-600 hover:text-gray-800">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</button>
<form action="/logout" method="post">
<button type="submit" class="text-gray-600 hover:text-gray-800">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</svg>
</button>
</form>
</div>
</div>
<!-- Page Tabs -->
<div id="page-tabs" class="flex space-x-1 -mb-px">
{{range .Pages}}
<button hx-get="/pages/{{.ID}}"
hx-target="#widget-grid"
hx-swap="innerHTML"
class="px-4 py-2 text-sm font-medium {{if .Active}}border-b-2 border-blue-500 text-blue-600{{else}}text-gray-600 hover:text-gray-800{{end}}">
{{.Name}}
</button>
{{end}}
<button class="px-4 py-2 text-sm font-medium text-gray-400 hover:text-gray-600">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
</button>
</div>
</div>
</header>
<!-- Widget Grid -->
<main class="flex-1 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 w-full">
<div id="widget-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Widgets will be loaded here via HTMX -->
<div class="col-span-full text-center text-gray-500 py-12">
<p>No widgets yet. Click the + button to add your first widget.</p>
</div>
</div>
</main>
</div>
{{end}}
{{define "scripts"}}
<script>
// Initialize Sortable.js for drag-and-drop when widgets are loaded
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'widget-grid') {
new Sortable(document.getElementById('widget-grid'), {
animation: 150,
handle: '.widget-handle',
onEnd: function(evt) {
// Send reorder request
const widgetIds = Array.from(evt.to.children).map(el => el.dataset.widgetId);
htmx.ajax('POST', '/widgets/reorder', {
values: { order: widgetIds.join(',') }
});
}
});
}
});
</script>
{{end}}

View File

@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}Custom Start Page{{end}}</title>
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Sortable.js for drag-and-drop -->
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
<!-- Prism.js for syntax highlighting -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css" rel="stylesheet" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-go.min.js"></script>
<!-- Custom CSS -->
<link rel="stylesheet" href="/static/css/main.css">
{{block "head" .}}{{end}}
</head>
<body class="bg-gray-50 min-h-screen">
{{block "content" .}}{{end}}
<!-- Custom JavaScript -->
<script src="/static/js/main.js"></script>
{{block "scripts" .}}{{end}}
</body>
</html>

45
templates/login.html Normal file
View File

@@ -0,0 +1,45 @@
{{template "layouts/base.html" .}}
{{define "title"}}Login - Custom Start Page{{end}}
{{define "content"}}
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-500 to-purple-600">
<div class="bg-white p-8 rounded-lg shadow-2xl w-full max-w-md">
<h1 class="text-3xl font-bold text-center mb-2 text-gray-800">Custom Start Page</h1>
<p class="text-center text-gray-600 mb-8">Sign in to access your personalized dashboard</p>
{{if .Error}}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
<p>{{.Error}}</p>
</div>
{{end}}
<div class="space-y-4">
<a href="/auth/oauth/google"
class="flex items-center justify-center w-full bg-white border-2 border-gray-300 rounded-lg px-6 py-3 text-gray-700 font-semibold hover:bg-gray-50 hover:border-gray-400 transition duration-200">
<svg class="w-6 h-6 mr-3" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Sign in with Google
</a>
<!-- Placeholder for additional OAuth providers -->
{{range .OAuthProviders}}
{{if ne .Name "google"}}
<a href="/auth/oauth/{{.Name}}"
class="flex items-center justify-center w-full bg-gray-800 rounded-lg px-6 py-3 text-white font-semibold hover:bg-gray-700 transition duration-200">
Sign in with {{.DisplayName}}
</a>
{{end}}
{{end}}
</div>
<p class="text-center text-gray-500 text-sm mt-8">
By signing in, you agree to our Terms of Service and Privacy Policy
</p>
</div>
</div>
{{end}}