Compare commits
5 Commits
3c372878a3
...
1.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 674efefc0c | |||
| 0e582595f7 | |||
| 1417023395 | |||
| 3cf9601a71 | |||
| 2ea90554ef |
@@ -2,6 +2,7 @@ node_modules
|
|||||||
npm-debug.log
|
npm-debug.log
|
||||||
data
|
data
|
||||||
.env
|
.env
|
||||||
|
.env.example
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
*.md
|
*.md
|
||||||
|
|||||||
48
.env.example
Normal file
48
.env.example
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Application Configuration
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
# Option 1: Use DATABASE_URL (recommended for production)
|
||||||
|
# DATABASE_URL=postgresql://user:password@host:port/database
|
||||||
|
# DATABASE_SSL=true
|
||||||
|
|
||||||
|
# Option 2: Use individual connection parameters (recommended for development)
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=linkding
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=postgres
|
||||||
|
|
||||||
|
# Session Configuration
|
||||||
|
SESSION_SECRET=your-secret-key-change-this-in-production
|
||||||
|
SESSION_NAME=connect.sid
|
||||||
|
COOKIE_SECURE=true
|
||||||
|
COOKIE_SAMESITE=none
|
||||||
|
COOKIE_DOMAIN=
|
||||||
|
COOKIE_PATH=/
|
||||||
|
|
||||||
|
# Proxy Configuration (for reverse proxy like Traefik)
|
||||||
|
TRUST_PROXY=true
|
||||||
|
|
||||||
|
# LDAP Authentication Configuration
|
||||||
|
LDAP_ADDRESS=ldap://ldap.example.com:389
|
||||||
|
LDAP_BASE_DN=dc=example,dc=com
|
||||||
|
LDAP_ADDITIONAL_USERS_DN=
|
||||||
|
LDAP_USER=cn=admin,dc=example,dc=com
|
||||||
|
LDAP_PASSWORD=admin_password
|
||||||
|
LDAP_USERS_FILTER=(&(objectClass=person)(uid={{username}}))
|
||||||
|
LDAP_TIMEOUT=5000
|
||||||
|
|
||||||
|
# LDAP Attribute Mapping
|
||||||
|
LDAP_ATTRIBUTE_USERNAME=uid
|
||||||
|
LDAP_ATTRIBUTE_MAIL=mail
|
||||||
|
LDAP_ATTRIBUTE_DISTINGUISHED_NAME=distinguishedName
|
||||||
|
LDAP_ATTRIBUTE_MEMBER_OF=memberOf
|
||||||
|
|
||||||
|
# LDAP TLS Configuration
|
||||||
|
LDAP_TLS_SKIP_VERIFY=false
|
||||||
|
LDAP_TLS_SERVER_NAME=
|
||||||
|
|
||||||
|
# Chrome/Chromium Configuration (for Puppeteer)
|
||||||
|
CHROME_EXECUTABLE_PATH=/usr/bin/chromium
|
||||||
23
Makefile
Normal file
23
Makefile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
.PHONY: dev up down clean
|
||||||
|
|
||||||
|
# Start development environment: PostgreSQL and the app
|
||||||
|
dev:
|
||||||
|
@echo "Starting PostgreSQL database..."
|
||||||
|
@docker compose -f docker-compose.dev.yaml up -d
|
||||||
|
@echo "Waiting for database to be ready..."
|
||||||
|
@sleep 3
|
||||||
|
@echo "Starting application..."
|
||||||
|
@npm start
|
||||||
|
|
||||||
|
# Start only the database
|
||||||
|
up:
|
||||||
|
@docker compose -f docker-compose.dev.yaml up -d
|
||||||
|
|
||||||
|
# Stop the database
|
||||||
|
down:
|
||||||
|
@docker compose -f docker-compose.dev.yaml down
|
||||||
|
|
||||||
|
# Stop and remove volumes (clean slate)
|
||||||
|
clean:
|
||||||
|
@docker compose -f docker-compose.dev.yaml down -v
|
||||||
|
|
||||||
167
README.md
167
README.md
@@ -7,22 +7,29 @@ LinkDing is a minimal bookmarking application where you can paste links and get
|
|||||||
- Paste links and get a list of links with title, description, and image
|
- Paste links and get a list of links with title, description, and image
|
||||||
- Automatic metadata extraction
|
- Automatic metadata extraction
|
||||||
- Search functionality by title, description, and URL
|
- Search functionality by title, description, and URL
|
||||||
|
- Organize links into custom lists
|
||||||
|
- Archive/unarchive links
|
||||||
|
- Public and private lists
|
||||||
- Modern, responsive web interface
|
- Modern, responsive web interface
|
||||||
- Support for JavaScript-heavy sites using Puppeteer
|
- Support for JavaScript-heavy sites using Puppeteer
|
||||||
- Automatic fallback from HTTP scraping to browser rendering
|
- Automatic fallback from HTTP scraping to browser rendering
|
||||||
|
- LDAP authentication support
|
||||||
|
- PostgreSQL database with automatic migrations
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Backend**: Express.js (Node.js)
|
- **Backend**: Express.js (Node.js)
|
||||||
- **Frontend**: Vanilla JavaScript, HTML5, CSS3
|
- **Frontend**: Vanilla JavaScript, HTML5, CSS3
|
||||||
- **Web Scraping**: Cheerio + Puppeteer (for JavaScript-heavy sites)
|
- **Web Scraping**: Cheerio + Puppeteer (for JavaScript-heavy sites)
|
||||||
- **Data Storage**: JSON file
|
- **Database**: PostgreSQL with Sequelize ORM
|
||||||
|
- **Authentication**: LDAP (optional)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js 18+ (or Docker)
|
- Node.js 18+ (or Docker)
|
||||||
|
- PostgreSQL 12+ (or Docker)
|
||||||
- Chromium/Chrome (for Puppeteer support, optional)
|
- Chromium/Chrome (for Puppeteer support, optional)
|
||||||
|
|
||||||
### Local Installation
|
### Local Installation
|
||||||
@@ -37,49 +44,86 @@ LinkDing is a minimal bookmarking application where you can paste links and get
|
|||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Start the server:
|
3. Set up environment variables:
|
||||||
```bash
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your database configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Start PostgreSQL database and the application:
|
||||||
|
```bash
|
||||||
|
make dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Or manually:
|
||||||
|
```bash
|
||||||
|
# Start PostgreSQL (using docker-compose)
|
||||||
|
docker compose -f docker-compose.dev.yaml up -d
|
||||||
|
|
||||||
|
# Start the application
|
||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Open your browser to `http://localhost:3000`
|
5. Open your browser to `http://localhost:3000`
|
||||||
|
|
||||||
|
**Note**: On first startup, the application will:
|
||||||
|
- Create database tables automatically
|
||||||
|
- Migrate any existing JSON files (`data/links.json` and `data/lists.json`) to the database
|
||||||
|
- Rename migrated JSON files to `*.json.bak`
|
||||||
|
|
||||||
### Docker Installation
|
### Docker Installation
|
||||||
|
|
||||||
1. Build the Docker image:
|
1. Set up environment variables:
|
||||||
```bash
|
```bash
|
||||||
docker build -t linkding .
|
cp .env.example .env
|
||||||
|
# Edit .env with your database configuration (or use defaults)
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Run the container:
|
2. Use Docker Compose (recommended):
|
||||||
```bash
|
|
||||||
docker run -d \
|
|
||||||
--name linkding \
|
|
||||||
-p 3000:3000 \
|
|
||||||
-v $(pwd)/data:/app/data \
|
|
||||||
linkding
|
|
||||||
```
|
|
||||||
|
|
||||||
Or use Docker Compose:
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This will start both PostgreSQL and the LinkDing application.
|
||||||
|
|
||||||
3. Access the application at `http://localhost:3000`
|
3. Access the application at `http://localhost:3000`
|
||||||
|
|
||||||
|
**Note**: The Docker Compose setup includes:
|
||||||
|
- PostgreSQL database with persistent volume
|
||||||
|
- LinkDing application container
|
||||||
|
- Automatic database initialization and migrations
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
1. **Add a Link**: Paste a URL into the input field and click "Add Link"
|
1. **Add a Link**: Paste a URL into the input field and click "Add Link"
|
||||||
2. **Search**: Use the search bar to filter links by title, description, or URL
|
2. **Search**: Use the search bar to filter links by title, description, or URL
|
||||||
3. **View Links**: Browse your saved links with images, titles, and descriptions
|
3. **View Links**: Browse your saved links with images, titles, and descriptions
|
||||||
4. **Delete Links**: Click the "Delete" button on any link card to remove it
|
4. **Organize Links**: Create lists and assign links to them
|
||||||
|
5. **Archive Links**: Archive links to hide them from the main view
|
||||||
|
6. **Public Lists**: Make lists public to share them with unauthenticated users
|
||||||
|
7. **Delete Links**: Click the "Delete" button on any link card to remove it
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
- `GET /api/links` - Get all saved links
|
### Links
|
||||||
|
- `GET /api/links` - Get all saved links (authenticated users see all, unauthenticated see only public lists)
|
||||||
- `GET /api/links/search?q=query` - Search links
|
- `GET /api/links/search?q=query` - Search links
|
||||||
- `POST /api/links` - Add a new link (body: `{ "url": "https://example.com" }`)
|
- `POST /api/links` - Add a new link (body: `{ "url": "https://example.com" }`) - Requires authentication
|
||||||
- `DELETE /api/links/:id` - Delete a link by ID
|
- `PATCH /api/links/:id/archive` - Archive/unarchive a link (body: `{ "archived": true }`) - Requires authentication
|
||||||
|
- `PATCH /api/links/:id/lists` - Update link's lists (body: `{ "listIds": ["uuid1", "uuid2"] }`) - Requires authentication
|
||||||
|
- `DELETE /api/links/:id` - Delete a link by ID - Requires authentication
|
||||||
|
|
||||||
|
### Lists
|
||||||
|
- `GET /api/lists` - Get all lists (authenticated users see all, unauthenticated see only public)
|
||||||
|
- `POST /api/lists` - Create a new list (body: `{ "name": "List Name" }`) - Requires authentication
|
||||||
|
- `PUT /api/lists/:id` - Update a list (body: `{ "name": "New Name" }`) - Requires authentication
|
||||||
|
- `PATCH /api/lists/:id/public` - Toggle list public status (body: `{ "public": true }`) - Requires authentication
|
||||||
|
- `DELETE /api/lists/:id` - Delete a list by ID - Requires authentication
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- `GET /api/auth/status` - Check authentication status
|
||||||
|
- `POST /api/auth/login` - Login with LDAP credentials (body: `{ "username": "user", "password": "pass" }`)
|
||||||
|
- `POST /api/auth/logout` - Logout
|
||||||
|
|
||||||
## Metadata Extraction
|
## Metadata Extraction
|
||||||
|
|
||||||
@@ -100,13 +144,63 @@ The application automatically extracts:
|
|||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
|
See `.env.example` for a complete list of environment variables. Key variables include:
|
||||||
|
|
||||||
|
### Application
|
||||||
- `PORT` - Server port (default: 3000)
|
- `PORT` - Server port (default: 3000)
|
||||||
- `CHROME_EXECUTABLE_PATH` - Path to Chrome/Chromium executable (for Puppeteer)
|
|
||||||
- `NODE_ENV` - Environment mode (production/development)
|
- `NODE_ENV` - Environment mode (production/development)
|
||||||
|
|
||||||
## Data Storage
|
### Database
|
||||||
|
- `DATABASE_URL` - Full PostgreSQL connection string (e.g., `postgresql://user:password@host:port/database`)
|
||||||
|
- `DATABASE_SSL` - Enable SSL for database connection (true/false)
|
||||||
|
- `DB_HOST` - Database host (default: localhost)
|
||||||
|
- `DB_PORT` - Database port (default: 5432)
|
||||||
|
- `DB_NAME` - Database name (default: linkding)
|
||||||
|
- `DB_USER` - Database user (default: postgres)
|
||||||
|
- `DB_PASSWORD` - Database password (default: postgres)
|
||||||
|
|
||||||
Links are stored in `data/links.json`. Make sure this directory exists and is writable. When using Docker, mount the `data` directory as a volume for persistence.
|
### Session & Cookies
|
||||||
|
- `SESSION_SECRET` - Secret key for session encryption (change in production!)
|
||||||
|
- `SESSION_NAME` - Session cookie name (default: connect.sid)
|
||||||
|
- `COOKIE_SECURE` - Use secure cookies (default: true in production)
|
||||||
|
- `COOKIE_SAMESITE` - Cookie SameSite attribute (default: none for secure, lax otherwise)
|
||||||
|
- `COOKIE_DOMAIN` - Cookie domain (optional)
|
||||||
|
- `COOKIE_PATH` - Cookie path (default: /)
|
||||||
|
- `TRUST_PROXY` - Trust proxy headers (default: true)
|
||||||
|
|
||||||
|
### LDAP Authentication
|
||||||
|
- `LDAP_ADDRESS` - LDAP server address
|
||||||
|
- `LDAP_BASE_DN` - LDAP base distinguished name
|
||||||
|
- `LDAP_USER` - LDAP bind user
|
||||||
|
- `LDAP_PASSWORD` - LDAP bind password
|
||||||
|
- `LDAP_USERS_FILTER` - LDAP user search filter
|
||||||
|
- And more... (see `.env.example`)
|
||||||
|
|
||||||
|
### Puppeteer
|
||||||
|
- `CHROME_EXECUTABLE_PATH` - Path to Chrome/Chromium executable (for Puppeteer)
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
LinkDing uses PostgreSQL for data storage. The application automatically:
|
||||||
|
|
||||||
|
- **Creates tables** on first startup
|
||||||
|
- **Runs migrations** to keep the schema up to date
|
||||||
|
- **Migrates JSON files** if `data/links.json` or `data/lists.json` exist, then renames them to `*.json.bak`
|
||||||
|
|
||||||
|
### Migration System
|
||||||
|
|
||||||
|
The application includes a migration system for database schema changes:
|
||||||
|
- Migrations are stored in `migrations/` directory
|
||||||
|
- Migrations are automatically run on startup
|
||||||
|
- Each migration is tracked in the `SequelizeMeta` table
|
||||||
|
|
||||||
|
### Data Migration
|
||||||
|
|
||||||
|
If you have existing JSON files:
|
||||||
|
1. Place `links.json` and `lists.json` in the `data/` directory
|
||||||
|
2. Start the application
|
||||||
|
3. The files will be automatically migrated to PostgreSQL
|
||||||
|
4. Original files will be renamed to `links.json.bak` and `lists.json.bak`
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
@@ -127,10 +221,31 @@ Some sites block automated requests. The app automatically:
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
### Using Make (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start PostgreSQL and the application
|
||||||
|
make dev
|
||||||
|
|
||||||
|
# Start only PostgreSQL
|
||||||
|
make up
|
||||||
|
|
||||||
|
# Stop PostgreSQL
|
||||||
|
make down
|
||||||
|
|
||||||
|
# Stop and remove volumes (clean slate)
|
||||||
|
make clean
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Development Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
|
# Start PostgreSQL (using docker-compose)
|
||||||
|
docker compose -f docker-compose.dev.yaml up -d
|
||||||
|
|
||||||
# Run in development mode with auto-reload
|
# Run in development mode with auto-reload
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
@@ -138,6 +253,14 @@ npm run dev
|
|||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Database Management
|
||||||
|
|
||||||
|
The application uses Sequelize ORM with PostgreSQL. Database migrations are automatically run on startup. To manually manage the database:
|
||||||
|
|
||||||
|
- Connect to PostgreSQL: `psql -h localhost -U postgres -d linkding`
|
||||||
|
- Check migrations: Query the `SequelizeMeta` table
|
||||||
|
- View tables: `\dt` in psql
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
ISC
|
ISC
|
||||||
|
|||||||
23
docker-compose.dev.yaml
Normal file
23
docker-compose.dev.yaml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: linkding-postgres-dev
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=linkding
|
||||||
|
- POSTGRES_USER=postgres
|
||||||
|
- POSTGRES_PASSWORD=postgres
|
||||||
|
volumes:
|
||||||
|
- postgres-dev-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-dev-data:
|
||||||
|
|
||||||
@@ -1,6 +1,22 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: linkding-postgres
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=${DB_NAME:-linkding}
|
||||||
|
- POSTGRES_USER=${DB_USER:-postgres}
|
||||||
|
- POSTGRES_PASSWORD=${DB_PASSWORD:-postgres}
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
linkding:
|
linkding:
|
||||||
build: .
|
build: .
|
||||||
container_name: linkding
|
container_name: linkding
|
||||||
@@ -13,6 +29,14 @@ services:
|
|||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
- CHROME_EXECUTABLE_PATH=/usr/bin/chromium
|
- CHROME_EXECUTABLE_PATH=/usr/bin/chromium
|
||||||
|
- DB_HOST=postgres
|
||||||
|
- DB_PORT=5432
|
||||||
|
- DB_NAME=${DB_NAME:-linkding}
|
||||||
|
- DB_USER=${DB_USER:-postgres}
|
||||||
|
- DB_PASSWORD=${DB_PASSWORD:-postgres}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/api/links', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
|
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/api/links', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
|
||||||
@@ -21,3 +45,6 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 5s
|
start_period: 5s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
|
||||||
|
|||||||
136
migrations/001-initial-schema.js
Normal file
136
migrations/001-initial-schema.js
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Initial database schema migration
|
||||||
|
* Creates links, lists, and link_lists tables
|
||||||
|
*/
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
// Create links table
|
||||||
|
await queryInterface.createTable('links', {
|
||||||
|
id: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
defaultValue: Sequelize.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||||
|
},
|
||||||
|
created_by: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
modified_at: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
modified_by: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
archived: {
|
||||||
|
type: Sequelize.BOOLEAN,
|
||||||
|
defaultValue: false,
|
||||||
|
allowNull: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create lists table
|
||||||
|
await queryInterface.createTable('lists', {
|
||||||
|
id: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
defaultValue: Sequelize.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||||
|
},
|
||||||
|
created_by: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
modified_at: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
modified_by: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
public: {
|
||||||
|
type: Sequelize.BOOLEAN,
|
||||||
|
defaultValue: false,
|
||||||
|
allowNull: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create link_lists junction table
|
||||||
|
await queryInterface.createTable('link_lists', {
|
||||||
|
link_id: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'links',
|
||||||
|
key: 'id'
|
||||||
|
},
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
onDelete: 'CASCADE'
|
||||||
|
},
|
||||||
|
list_id: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'lists',
|
||||||
|
key: 'id'
|
||||||
|
},
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
onDelete: 'CASCADE'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add composite primary key
|
||||||
|
await queryInterface.addConstraint('link_lists', {
|
||||||
|
fields: ['link_id', 'list_id'],
|
||||||
|
type: 'primary key',
|
||||||
|
name: 'link_lists_pkey'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create indexes for better performance
|
||||||
|
await queryInterface.addIndex('links', ['url'], { unique: true });
|
||||||
|
await queryInterface.addIndex('links', ['created_at']);
|
||||||
|
await queryInterface.addIndex('links', ['archived']);
|
||||||
|
await queryInterface.addIndex('lists', ['name']);
|
||||||
|
await queryInterface.addIndex('link_lists', ['link_id']);
|
||||||
|
await queryInterface.addIndex('link_lists', ['list_id']);
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.dropTable('link_lists');
|
||||||
|
await queryInterface.dropTable('lists');
|
||||||
|
await queryInterface.dropTable('links');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
104
migrations/runner.js
Normal file
104
migrations/runner.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
const { QueryInterface } = require('sequelize');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple migration runner for Sequelize
|
||||||
|
* Checks which migrations have been run and executes pending ones
|
||||||
|
*/
|
||||||
|
class MigrationRunner {
|
||||||
|
constructor(sequelize) {
|
||||||
|
this.sequelize = sequelize;
|
||||||
|
this.migrationsPath = path.join(__dirname);
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureMigrationsTable() {
|
||||||
|
const queryInterface = this.sequelize.getQueryInterface();
|
||||||
|
|
||||||
|
// Check if migrations table exists
|
||||||
|
const [results] = await this.sequelize.query(`
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'SequelizeMeta'
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (!results[0].exists) {
|
||||||
|
// Create migrations table
|
||||||
|
await queryInterface.createTable('SequelizeMeta', {
|
||||||
|
name: {
|
||||||
|
type: require('sequelize').DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
primaryKey: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getExecutedMigrations() {
|
||||||
|
await this.ensureMigrationsTable();
|
||||||
|
|
||||||
|
const [results] = await this.sequelize.query(
|
||||||
|
'SELECT name FROM "SequelizeMeta" ORDER BY name'
|
||||||
|
);
|
||||||
|
|
||||||
|
return results.map(row => row.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllMigrations() {
|
||||||
|
const files = await fs.readdir(this.migrationsPath);
|
||||||
|
return files
|
||||||
|
.filter(file => file.endsWith('.js') && file !== 'runner.js')
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async runMigrations() {
|
||||||
|
const executed = await this.getExecutedMigrations();
|
||||||
|
const allMigrations = await this.getAllMigrations();
|
||||||
|
const pending = allMigrations.filter(m => !executed.includes(m));
|
||||||
|
|
||||||
|
if (pending.length === 0) {
|
||||||
|
console.log('No pending migrations');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Running ${pending.length} pending migration(s)...`);
|
||||||
|
|
||||||
|
for (const migrationFile of pending) {
|
||||||
|
const migration = require(path.join(this.migrationsPath, migrationFile));
|
||||||
|
|
||||||
|
if (!migration.up || typeof migration.up !== 'function') {
|
||||||
|
throw new Error(`Migration ${migrationFile} does not export an 'up' function`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryInterface = this.sequelize.getQueryInterface();
|
||||||
|
const transaction = await this.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Running migration: ${migrationFile}`);
|
||||||
|
await migration.up(queryInterface, this.sequelize.constructor);
|
||||||
|
|
||||||
|
// Record migration as executed
|
||||||
|
await this.sequelize.query(
|
||||||
|
`INSERT INTO "SequelizeMeta" (name) VALUES (:name)`,
|
||||||
|
{
|
||||||
|
replacements: { name: migrationFile },
|
||||||
|
transaction
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
console.log(`Completed migration: ${migrationFile}`);
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw new Error(`Migration ${migrationFile} failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('All migrations completed successfully');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = MigrationRunner;
|
||||||
|
|
||||||
66
models/Link.js
Normal file
66
models/Link.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
|
||||||
|
module.exports = (sequelize) => {
|
||||||
|
const Link = sequelize.define('Link', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true,
|
||||||
|
validate: {
|
||||||
|
isUrl: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
defaultValue: DataTypes.NOW,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
created_by: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
modified_at: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
modified_by: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
archived: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false,
|
||||||
|
allowNull: false
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'links',
|
||||||
|
timestamps: false, // We're using created_at and modified_at manually
|
||||||
|
underscored: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hook to set modified_at before update
|
||||||
|
Link.beforeUpdate((link) => {
|
||||||
|
link.modified_at = new Date();
|
||||||
|
});
|
||||||
|
|
||||||
|
return Link;
|
||||||
|
};
|
||||||
|
|
||||||
33
models/LinkList.js
Normal file
33
models/LinkList.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
|
||||||
|
module.exports = (sequelize) => {
|
||||||
|
const LinkList = sequelize.define('LinkList', {
|
||||||
|
link_id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
primaryKey: true,
|
||||||
|
references: {
|
||||||
|
model: 'links',
|
||||||
|
key: 'id'
|
||||||
|
},
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
onUpdate: 'CASCADE'
|
||||||
|
},
|
||||||
|
list_id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
primaryKey: true,
|
||||||
|
references: {
|
||||||
|
model: 'lists',
|
||||||
|
key: 'id'
|
||||||
|
},
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
onUpdate: 'CASCADE'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'link_lists',
|
||||||
|
timestamps: false, // No timestamps on junction table
|
||||||
|
underscored: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return LinkList;
|
||||||
|
};
|
||||||
|
|
||||||
53
models/List.js
Normal file
53
models/List.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
|
||||||
|
module.exports = (sequelize) => {
|
||||||
|
const List = sequelize.define('List', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
validate: {
|
||||||
|
notEmpty: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
defaultValue: DataTypes.NOW,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
created_by: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
modified_at: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
modified_by: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
public: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false,
|
||||||
|
allowNull: false
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'lists',
|
||||||
|
timestamps: false, // We're using created_at and modified_at manually
|
||||||
|
underscored: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hook to set modified_at before update
|
||||||
|
List.beforeUpdate((list) => {
|
||||||
|
list.modified_at = new Date();
|
||||||
|
});
|
||||||
|
|
||||||
|
return List;
|
||||||
|
};
|
||||||
|
|
||||||
73
models/index.js
Normal file
73
models/index.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
const { Sequelize } = require('sequelize');
|
||||||
|
const Link = require('./Link');
|
||||||
|
const List = require('./List');
|
||||||
|
const LinkList = require('./LinkList');
|
||||||
|
|
||||||
|
// Database connection configuration
|
||||||
|
const getDatabaseConfig = () => {
|
||||||
|
// Support DATABASE_URL or individual connection parameters
|
||||||
|
if (process.env.DATABASE_URL) {
|
||||||
|
return {
|
||||||
|
url: process.env.DATABASE_URL,
|
||||||
|
dialect: 'postgres',
|
||||||
|
dialectOptions: {
|
||||||
|
ssl: process.env.DATABASE_SSL === 'true' ? {
|
||||||
|
require: true,
|
||||||
|
rejectUnauthorized: false
|
||||||
|
} : false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: process.env.DB_PORT || 5432,
|
||||||
|
database: process.env.DB_NAME || 'linkding',
|
||||||
|
username: process.env.DB_USER || 'postgres',
|
||||||
|
password: process.env.DB_PASSWORD || 'postgres',
|
||||||
|
dialect: 'postgres',
|
||||||
|
logging: process.env.NODE_ENV === 'development' ? console.log : false
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize Sequelize
|
||||||
|
const config = getDatabaseConfig();
|
||||||
|
const sequelize = config.url
|
||||||
|
? new Sequelize(config.url, {
|
||||||
|
dialect: 'postgres',
|
||||||
|
dialectOptions: config.dialectOptions,
|
||||||
|
logging: config.logging
|
||||||
|
})
|
||||||
|
: new Sequelize(config.database, config.username, config.password, {
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
dialect: config.dialect,
|
||||||
|
logging: config.logging
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize models
|
||||||
|
const db = {
|
||||||
|
sequelize,
|
||||||
|
Sequelize,
|
||||||
|
Link: Link(sequelize),
|
||||||
|
List: List(sequelize),
|
||||||
|
LinkList: LinkList(sequelize)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up associations
|
||||||
|
db.Link.belongsToMany(db.List, {
|
||||||
|
through: db.LinkList,
|
||||||
|
foreignKey: 'link_id',
|
||||||
|
otherKey: 'list_id',
|
||||||
|
as: 'lists'
|
||||||
|
});
|
||||||
|
|
||||||
|
db.List.belongsToMany(db.Link, {
|
||||||
|
through: db.LinkList,
|
||||||
|
foreignKey: 'list_id',
|
||||||
|
otherKey: 'link_id',
|
||||||
|
as: 'links'
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = db;
|
||||||
|
|
||||||
672
package-lock.json
generated
672
package-lock.json
generated
@@ -1,19 +1,27 @@
|
|||||||
{
|
{
|
||||||
"name": "linkding",
|
"name": "linkding",
|
||||||
"version": "1.0.0",
|
"version": "1.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "linkding",
|
"name": "linkding",
|
||||||
"version": "1.0.0",
|
"version": "1.2.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"cheerio": "^1.0.0-rc.12",
|
"cheerio": "^1.0.0-rc.12",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"puppeteer-core": "^22.15.0"
|
"express-session": "^1.18.2",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-ldapauth": "^3.0.1",
|
||||||
|
"pg": "^8.11.3",
|
||||||
|
"pg-hstore": "^2.3.4",
|
||||||
|
"puppeteer-core": "^22.15.0",
|
||||||
|
"sequelize": "^6.35.2",
|
||||||
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.1"
|
"nodemon": "^3.0.1"
|
||||||
@@ -25,16 +33,45 @@
|
|||||||
"integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==",
|
"integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/debug": {
|
||||||
|
"version": "4.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||||
|
"integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/ms": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/ldapjs": {
|
||||||
|
"version": "2.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ldapjs/-/ldapjs-2.2.5.tgz",
|
||||||
|
"integrity": "sha512-Lv/nD6QDCmcT+V1vaTRnEKE8UgOilVv5pHcQuzkU1LcRe4mbHHuUo/KHi0LKrpdHhQY8FJzryF38fcVdeUIrzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/ms": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.10.0",
|
"version": "24.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz",
|
||||||
"integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==",
|
"integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/validator": {
|
||||||
|
"version": "13.15.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.9.tgz",
|
||||||
|
"integrity": "sha512-9ENIuq9PUX45M1QRtfJDprgfErED4fBiMPmjlPci4W9WiBelVtHYCjF3xkQNcSnmUeuruLS1kH6hSl5M1vz4Sw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/yauzl": {
|
"node_modules/@types/yauzl": {
|
||||||
"version": "2.10.3",
|
"version": "2.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||||
@@ -45,6 +82,12 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/abstract-logging": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
@@ -111,6 +154,24 @@
|
|||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/asn1": {
|
||||||
|
"version": "0.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
||||||
|
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": "~2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/assert-plus": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ast-types": {
|
"node_modules/ast-types": {
|
||||||
"version": "0.13.4",
|
"version": "0.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
|
||||||
@@ -154,6 +215,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/backoff": {
|
||||||
|
"version": "2.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz",
|
||||||
|
"integrity": "sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"precond": "0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@@ -281,6 +354,12 @@
|
|||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "2.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
||||||
|
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
@@ -599,6 +678,12 @@
|
|||||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/core-util-is": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cors": {
|
"node_modules/cors": {
|
||||||
"version": "2.8.5",
|
"version": "2.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
|
||||||
@@ -761,6 +846,24 @@
|
|||||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dotenv": {
|
||||||
|
"version": "17.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||||
|
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dottie": {
|
||||||
|
"version": "2.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.6.tgz",
|
||||||
|
"integrity": "sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -1006,6 +1109,40 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-session": {
|
||||||
|
"version": "1.18.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz",
|
||||||
|
"integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "0.7.2",
|
||||||
|
"cookie-signature": "1.0.7",
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"depd": "~2.0.0",
|
||||||
|
"on-headers": "~1.1.0",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"safe-buffer": "5.2.1",
|
||||||
|
"uid-safe": "~2.1.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express-session/node_modules/cookie": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express-session/node_modules/cookie-signature": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/extract-zip": {
|
"node_modules/extract-zip": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
|
||||||
@@ -1049,6 +1186,15 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/extsprintf": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==",
|
||||||
|
"engines": [
|
||||||
|
"node >=0.6.0"
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-fifo": {
|
"node_modules/fast-fifo": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
|
||||||
@@ -1503,6 +1649,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/inflection": {
|
||||||
|
"version": "1.13.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz",
|
||||||
|
"integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==",
|
||||||
|
"engines": [
|
||||||
|
"node >= 0.4.0"
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/inherits": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
@@ -1582,6 +1737,59 @@
|
|||||||
"node": ">=0.12.0"
|
"node": ">=0.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ldap-filter": {
|
||||||
|
"version": "0.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ldap-filter/-/ldap-filter-0.3.3.tgz",
|
||||||
|
"integrity": "sha512-/tFkx5WIn4HuO+6w9lsfxq4FN3O+fDZeO9Mek8dCD8rTUpqzRa766BOBO7BcGkn3X86m5+cBm1/2S/Shzz7gMg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"assert-plus": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ldapauth-fork": {
|
||||||
|
"version": "5.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/ldapauth-fork/-/ldapauth-fork-5.0.5.tgz",
|
||||||
|
"integrity": "sha512-LWUk76+V4AOZbny/3HIPQtGPWZyA3SW2tRhsWIBi9imP22WJktKLHV1ofd8Jo/wY7Ve6vAT7FCI5mEn3blZTjw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/ldapjs": "^2.2.2",
|
||||||
|
"bcryptjs": "^2.4.0",
|
||||||
|
"ldapjs": "^2.2.1",
|
||||||
|
"lru-cache": "^7.10.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ldapjs": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ldapjs/-/ldapjs-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-75QiiLJV/PQqtpH+HGls44dXweviFwQ6SiIK27EqzKQ5jU/7UFrl2E5nLdQ3IYRBzJ/AVFJI66u0MZ0uofKYwg==",
|
||||||
|
"deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"abstract-logging": "^2.0.0",
|
||||||
|
"asn1": "^0.2.4",
|
||||||
|
"assert-plus": "^1.0.0",
|
||||||
|
"backoff": "^2.5.0",
|
||||||
|
"ldap-filter": "^0.3.3",
|
||||||
|
"once": "^1.4.0",
|
||||||
|
"vasync": "^2.2.0",
|
||||||
|
"verror": "^1.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.17.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "7.18.3",
|
"version": "7.18.3",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
|
||||||
@@ -1679,6 +1887,27 @@
|
|||||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/moment": {
|
||||||
|
"version": "2.30.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||||
|
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/moment-timezone": {
|
||||||
|
"version": "0.5.48",
|
||||||
|
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz",
|
||||||
|
"integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"moment": "^2.29.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
@@ -1812,6 +2041,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/on-headers": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/once": {
|
"node_modules/once": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
@@ -1934,18 +2172,163 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/passport": {
|
||||||
|
"version": "0.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
|
||||||
|
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"passport-strategy": "1.x.x",
|
||||||
|
"pause": "0.0.1",
|
||||||
|
"utils-merge": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/jaredhanson"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/passport-ldapauth": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/passport-ldapauth/-/passport-ldapauth-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-TRRx3BHi8GC8MfCT9wmghjde/EGeKjll7zqHRRfGRxXbLcaDce2OftbQrFG7/AWaeFhR6zpZHtBQ/IkINdLVjQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ldapauth-fork": "^5.0.1",
|
||||||
|
"passport-strategy": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/passport-strategy": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "0.1.12",
|
"version": "0.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pause": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
|
||||||
|
},
|
||||||
"node_modules/pend": {
|
"node_modules/pend": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||||
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.16.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||||
|
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.9.1",
|
||||||
|
"pg-pool": "^3.10.1",
|
||||||
|
"pg-protocol": "^1.10.3",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.2.7"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
|
||||||
|
"integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
|
||||||
|
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-hstore": {
|
||||||
|
"version": "2.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-hstore/-/pg-hstore-2.3.4.tgz",
|
||||||
|
"integrity": "sha512-N3SGs/Rf+xA1M2/n0JBiXFDVMzdekwLZLAO0g7mpDY9ouX+fDI7jS6kTq3JujmYbtNSJ53TJ0q4G98KVZSM4EA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"underscore": "^1.13.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.10.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
|
||||||
|
"integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||||
@@ -1959,6 +2342,53 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/precond": {
|
||||||
|
"version": "0.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz",
|
||||||
|
"integrity": "sha512-QCYG84SgGyGzqJ/vlMsxeXd/pgL/I94ixdNFyh1PusWmTCyVfPJjZ1K1jvHtsbfnXQs2TSkEP2fR7QiMZAnKFQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/progress": {
|
"node_modules/progress": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||||
@@ -2113,6 +2543,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/random-bytes": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/range-parser": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
@@ -2171,6 +2610,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/retry-as-promised": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz",
|
||||||
|
"integrity": "sha512-hMD7odLOt3LkTjcif8aRZqi/hybjpLNgSk5oF5FCowfCjok6LukpN2bDX7R5wDmbgBQFn7YoBxSagmtXHaJYJw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/safe-buffer": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -2248,6 +2693,109 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/sequelize": {
|
||||||
|
"version": "6.37.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.37.7.tgz",
|
||||||
|
"integrity": "sha512-mCnh83zuz7kQxxJirtFD7q6Huy6liPanI67BSlbzSYgVNl5eXVdE2CN1FuAeZwG1SNpGsNRCV+bJAVVnykZAFA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/sequelize"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/debug": "^4.1.8",
|
||||||
|
"@types/validator": "^13.7.17",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"dottie": "^2.0.6",
|
||||||
|
"inflection": "^1.13.4",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"moment": "^2.29.4",
|
||||||
|
"moment-timezone": "^0.5.43",
|
||||||
|
"pg-connection-string": "^2.6.1",
|
||||||
|
"retry-as-promised": "^7.0.4",
|
||||||
|
"semver": "^7.5.4",
|
||||||
|
"sequelize-pool": "^7.1.0",
|
||||||
|
"toposort-class": "^1.0.1",
|
||||||
|
"uuid": "^8.3.2",
|
||||||
|
"validator": "^13.9.0",
|
||||||
|
"wkx": "^0.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"ibm_db": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"mariadb": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"mysql2": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"oracledb": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"pg": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"pg-hstore": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"snowflake-sdk": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"sqlite3": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"tedious": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sequelize-pool": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sequelize/node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sequelize/node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/sequelize/node_modules/uuid": {
|
||||||
|
"version": "8.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||||
|
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/serve-static": {
|
"node_modules/serve-static": {
|
||||||
"version": "1.16.2",
|
"version": "1.16.2",
|
||||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
|
||||||
@@ -2425,6 +2973,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||||
@@ -2532,6 +3089,12 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/toposort-class": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/toposort-class/-/toposort-class-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/touch": {
|
"node_modules/touch": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
|
||||||
@@ -2561,6 +3124,18 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uid-safe": {
|
||||||
|
"version": "2.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
|
||||||
|
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"random-bytes": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/unbzip2-stream": {
|
"node_modules/unbzip2-stream": {
|
||||||
"version": "1.4.3",
|
"version": "1.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz",
|
||||||
@@ -2578,6 +3153,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/underscore": {
|
||||||
|
"version": "1.13.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz",
|
||||||
|
"integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "7.16.0",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz",
|
||||||
@@ -2591,8 +3172,7 @@
|
|||||||
"version": "7.16.0",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@@ -2618,6 +3198,28 @@
|
|||||||
"node": ">= 0.4.0"
|
"node": ">= 0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "9.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||||
|
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/validator": {
|
||||||
|
"version": "13.15.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz",
|
||||||
|
"integrity": "sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
@@ -2627,6 +3229,46 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vasync": {
|
||||||
|
"version": "2.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/vasync/-/vasync-2.2.1.tgz",
|
||||||
|
"integrity": "sha512-Hq72JaTpcTFdWiNA4Y22Amej2GH3BFmBaKPPlDZ4/oC8HNn2ISHLkFrJU4Ds8R3jcUi7oo5Y9jcMHKjES+N9wQ==",
|
||||||
|
"engines": [
|
||||||
|
"node >=0.6.0"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"verror": "1.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vasync/node_modules/verror": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==",
|
||||||
|
"engines": [
|
||||||
|
"node >=0.6.0"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"assert-plus": "^1.0.0",
|
||||||
|
"core-util-is": "1.0.2",
|
||||||
|
"extsprintf": "^1.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/verror": {
|
||||||
|
"version": "1.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz",
|
||||||
|
"integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"assert-plus": "^1.0.0",
|
||||||
|
"core-util-is": "1.0.2",
|
||||||
|
"extsprintf": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/whatwg-encoding": {
|
"node_modules/whatwg-encoding": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||||
@@ -2648,6 +3290,15 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wkx": {
|
||||||
|
"version": "0.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz",
|
||||||
|
"integrity": "sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wrap-ansi": {
|
"node_modules/wrap-ansi": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
@@ -2692,6 +3343,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xtend": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "linkding",
|
"name": "linkding",
|
||||||
"version": "1.0.0",
|
"version": "1.2.0",
|
||||||
"description": "A modern link bookmarking app with metadata extraction",
|
"description": "A modern link bookmarking app with metadata extraction",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -18,8 +18,16 @@
|
|||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"cheerio": "^1.0.0-rc.12",
|
"cheerio": "^1.0.0-rc.12",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"puppeteer-core": "^22.15.0"
|
"express-session": "^1.18.2",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-ldapauth": "^3.0.1",
|
||||||
|
"pg": "^8.11.3",
|
||||||
|
"pg-hstore": "^2.3.4",
|
||||||
|
"puppeteer-core": "^22.15.0",
|
||||||
|
"sequelize": "^6.35.2",
|
||||||
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.1"
|
"nodemon": "^3.0.1"
|
||||||
|
|||||||
432
public/app.js
432
public/app.js
@@ -1,5 +1,6 @@
|
|||||||
const API_BASE = '/api/links';
|
const API_BASE = '/api/links';
|
||||||
const LISTS_API_BASE = '/api/lists';
|
const LISTS_API_BASE = '/api/lists';
|
||||||
|
const AUTH_API_BASE = '/api/auth';
|
||||||
|
|
||||||
// DOM elements
|
// DOM elements
|
||||||
const linkForm = document.getElementById('linkForm');
|
const linkForm = document.getElementById('linkForm');
|
||||||
@@ -29,6 +30,17 @@ const listFilterWrapper = document.getElementById('listFilterWrapper');
|
|||||||
const listFilterChips = document.getElementById('listFilterChips');
|
const listFilterChips = document.getElementById('listFilterChips');
|
||||||
const clearListFilters = document.getElementById('clearListFilters');
|
const clearListFilters = document.getElementById('clearListFilters');
|
||||||
const editListsBtn = document.getElementById('editListsBtn');
|
const editListsBtn = document.getElementById('editListsBtn');
|
||||||
|
const loginToggle = document.getElementById('loginToggle');
|
||||||
|
const loginModal = document.getElementById('loginModal');
|
||||||
|
const closeLoginModal = document.getElementById('closeLoginModal');
|
||||||
|
const loginForm = document.getElementById('loginForm');
|
||||||
|
const loginUsername = document.getElementById('loginUsername');
|
||||||
|
const loginPassword = document.getElementById('loginPassword');
|
||||||
|
const loginSubmitBtn = document.getElementById('loginSubmitBtn');
|
||||||
|
const loginError = document.getElementById('loginError');
|
||||||
|
const userInfo = document.getElementById('userInfo');
|
||||||
|
const usernameDisplay = document.getElementById('usernameDisplay');
|
||||||
|
const logoutBtn = document.getElementById('logoutBtn');
|
||||||
|
|
||||||
// State
|
// State
|
||||||
let allLinks = [];
|
let allLinks = [];
|
||||||
@@ -38,15 +50,20 @@ let showArchived = false;
|
|||||||
let currentLayout = localStorage.getItem('linkdingLayout') || 'masonry'; // Load from localStorage or default
|
let currentLayout = localStorage.getItem('linkdingLayout') || 'masonry'; // Load from localStorage or default
|
||||||
let selectedListFilters = [];
|
let selectedListFilters = [];
|
||||||
let currentLinkForListSelection = null;
|
let currentLinkForListSelection = null;
|
||||||
|
let isAuthenticated = false;
|
||||||
|
let currentUser = null;
|
||||||
|
|
||||||
// Initialize app
|
// Initialize app
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadLinks();
|
checkAuthStatus().then(() => {
|
||||||
loadLists().then(() => {
|
loadLinks();
|
||||||
// After lists are loaded, check URL for list filter
|
loadLists().then(() => {
|
||||||
checkUrlForListFilter();
|
// After lists are loaded, check URL for list filter
|
||||||
|
checkUrlForListFilter();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
|
setupAuthentication();
|
||||||
setupScrollToTop();
|
setupScrollToTop();
|
||||||
setupMobileSearch();
|
setupMobileSearch();
|
||||||
setupMobileAddLink();
|
setupMobileAddLink();
|
||||||
@@ -54,10 +71,35 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
setupListsManagement();
|
setupListsManagement();
|
||||||
setupListFiltering();
|
setupListFiltering();
|
||||||
setupUrlNavigation();
|
setupUrlNavigation();
|
||||||
|
setupLogoClick();
|
||||||
|
setupMobileButtonBlur();
|
||||||
applyLayout(currentLayout);
|
applyLayout(currentLayout);
|
||||||
registerServiceWorker();
|
registerServiceWorker();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Setup button blur on mobile after click
|
||||||
|
function setupMobileButtonBlur() {
|
||||||
|
// Check if device is mobile/touch device
|
||||||
|
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
|
||||||
|
('ontouchstart' in window) ||
|
||||||
|
(navigator.maxTouchPoints > 0);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
// Add blur handler to all buttons after click
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (e.target.tagName === 'BUTTON' || e.target.closest('button')) {
|
||||||
|
const button = e.target.tagName === 'BUTTON' ? e.target : e.target.closest('button');
|
||||||
|
// Use setTimeout to ensure blur happens after any click handlers
|
||||||
|
setTimeout(() => {
|
||||||
|
if (button && document.activeElement === button) {
|
||||||
|
button.blur();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, true); // Use capture phase to catch all button clicks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Register service worker for PWA
|
// Register service worker for PWA
|
||||||
function registerServiceWorker() {
|
function registerServiceWorker() {
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
@@ -73,11 +115,212 @@ function registerServiceWorker() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authentication functions
|
||||||
|
async function checkAuthStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(AUTH_API_BASE + '/status', {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to check auth status');
|
||||||
|
const data = await response.json();
|
||||||
|
const wasAuthenticated = isAuthenticated;
|
||||||
|
isAuthenticated = data.authenticated;
|
||||||
|
currentUser = data.user;
|
||||||
|
updateUIBasedOnAuth();
|
||||||
|
|
||||||
|
// If authentication status changed, reload lists to get the correct set
|
||||||
|
if (wasAuthenticated !== isAuthenticated) {
|
||||||
|
await loadLists();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking auth status:', error);
|
||||||
|
isAuthenticated = false;
|
||||||
|
currentUser = null;
|
||||||
|
updateUIBasedOnAuth();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUIBasedOnAuth() {
|
||||||
|
// Show/hide login button and user info
|
||||||
|
if (isAuthenticated) {
|
||||||
|
loginToggle.style.display = 'none';
|
||||||
|
userInfo.style.display = 'flex';
|
||||||
|
usernameDisplay.textContent = currentUser?.username || 'User';
|
||||||
|
} else {
|
||||||
|
loginToggle.style.display = 'flex';
|
||||||
|
userInfo.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide modify buttons (lists toggle is visible to all for filtering)
|
||||||
|
const modifyButtons = document.querySelectorAll('.add-link-toggle-btn, .delete-btn, .archive-btn, .add-to-list-btn, #createListBtn, .edit-lists-btn');
|
||||||
|
modifyButtons.forEach(btn => {
|
||||||
|
if (btn) {
|
||||||
|
btn.style.display = isAuthenticated ? '' : 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lists toggle button is visible to all users for filtering
|
||||||
|
// But the edit button inside the list filter section should be hidden when not authenticated
|
||||||
|
const editListsBtn = document.getElementById('editListsBtn');
|
||||||
|
if (editListsBtn) {
|
||||||
|
editListsBtn.style.display = isAuthenticated ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide add link wrapper if not authenticated
|
||||||
|
if (!isAuthenticated && addLinkWrapper) {
|
||||||
|
addLinkWrapper.classList.remove('show');
|
||||||
|
if (addLinkToggle) addLinkToggle.classList.remove('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupAuthentication() {
|
||||||
|
// Login toggle
|
||||||
|
loginToggle.addEventListener('click', () => {
|
||||||
|
loginModal.classList.add('show');
|
||||||
|
loginUsername.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close login modal
|
||||||
|
closeLoginModal.addEventListener('click', () => {
|
||||||
|
loginModal.classList.remove('show');
|
||||||
|
loginForm.reset();
|
||||||
|
loginError.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on backdrop click
|
||||||
|
loginModal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === loginModal) {
|
||||||
|
loginModal.classList.remove('show');
|
||||||
|
loginForm.reset();
|
||||||
|
loginError.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login form submission
|
||||||
|
loginForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const username = loginUsername.value.trim();
|
||||||
|
const password = loginPassword.value;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
showLoginError('Please enter both username and password');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable form
|
||||||
|
loginSubmitBtn.disabled = true;
|
||||||
|
const btnText = loginSubmitBtn.querySelector('.btn-text');
|
||||||
|
const btnLoader = loginSubmitBtn.querySelector('.btn-loader');
|
||||||
|
if (btnText) btnText.style.display = 'none';
|
||||||
|
if (btnLoader) btnLoader.style.display = 'flex';
|
||||||
|
loginError.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(AUTH_API_BASE + '/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Login failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
isAuthenticated = true;
|
||||||
|
currentUser = data.user;
|
||||||
|
updateUIBasedOnAuth();
|
||||||
|
|
||||||
|
loginModal.classList.remove('show');
|
||||||
|
loginForm.reset();
|
||||||
|
showMessage('Login successful!', 'success');
|
||||||
|
|
||||||
|
// Reload lists to get all lists (not just public ones)
|
||||||
|
loadLists().then(() => {
|
||||||
|
// Update filter chips if visible
|
||||||
|
if (listFilterWrapper.classList.contains('show')) {
|
||||||
|
updateListFilterChips();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh links to show all links (not just public ones)
|
||||||
|
loadLinks();
|
||||||
|
} catch (error) {
|
||||||
|
showLoginError(error.message || 'Login failed. Please check your credentials.');
|
||||||
|
console.error('Login error:', error);
|
||||||
|
} finally {
|
||||||
|
loginSubmitBtn.disabled = false;
|
||||||
|
if (btnText) btnText.style.display = 'inline';
|
||||||
|
if (btnLoader) btnLoader.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
logoutBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(AUTH_API_BASE + '/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Logout failed');
|
||||||
|
|
||||||
|
isAuthenticated = false;
|
||||||
|
currentUser = null;
|
||||||
|
updateUIBasedOnAuth();
|
||||||
|
showMessage('Logged out successfully', 'success');
|
||||||
|
|
||||||
|
// Reload lists to get only public lists
|
||||||
|
loadLists().then(() => {
|
||||||
|
// Clear any selected filters that are no longer valid (private lists)
|
||||||
|
selectedListFilters = selectedListFilters.filter(listId => {
|
||||||
|
return allLists.some(list => list.id === listId && list.public === true);
|
||||||
|
});
|
||||||
|
// Update filter chips if visible
|
||||||
|
if (listFilterWrapper.classList.contains('show')) {
|
||||||
|
updateListFilterChips();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset all filters and refresh links to show only public links
|
||||||
|
resetAllFilters();
|
||||||
|
} catch (error) {
|
||||||
|
showMessage('Logout failed', 'error');
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoginError(message) {
|
||||||
|
loginError.textContent = message;
|
||||||
|
loginError.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to handle API errors, especially authentication errors
|
||||||
|
async function handleApiError(response, defaultMessage) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
// Authentication required
|
||||||
|
isAuthenticated = false;
|
||||||
|
currentUser = null;
|
||||||
|
updateUIBasedOnAuth();
|
||||||
|
showMessage('Please login to perform this action', 'error');
|
||||||
|
loginModal.classList.add('show');
|
||||||
|
return true; // Indicates auth error was handled
|
||||||
|
}
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || defaultMessage);
|
||||||
|
}
|
||||||
|
|
||||||
// Event listeners
|
// Event listeners
|
||||||
function setupEventListeners() {
|
function setupEventListeners() {
|
||||||
linkForm.addEventListener('submit', handleAddLink);
|
linkForm.addEventListener('submit', handleAddLink);
|
||||||
searchInput.addEventListener('input', handleSearch);
|
searchInput.addEventListener('input', handleSearch);
|
||||||
archiveToggle.addEventListener('click', handleToggleArchive);
|
archiveToggle.addEventListener('change', handleToggleArchive);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup search toggle (works on both desktop and mobile)
|
// Setup search toggle (works on both desktop and mobile)
|
||||||
@@ -91,10 +334,20 @@ function setupMobileSearch() {
|
|||||||
// Hide search
|
// Hide search
|
||||||
searchWrapper.classList.remove('show');
|
searchWrapper.classList.remove('show');
|
||||||
searchToggle.classList.remove('active');
|
searchToggle.classList.remove('active');
|
||||||
|
// Force remove active state on mobile - use requestAnimationFrame to ensure it happens after click processing
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
searchToggle.blur();
|
||||||
|
if (document.activeElement === searchToggle) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
// Hide add link if visible
|
// Hide add link if visible
|
||||||
if (addLinkWrapper && addLinkWrapper.classList.contains('show')) {
|
if (addLinkWrapper && addLinkWrapper.classList.contains('show')) {
|
||||||
addLinkWrapper.classList.remove('show');
|
addLinkWrapper.classList.remove('show');
|
||||||
if (addLinkToggle) addLinkToggle.classList.remove('active');
|
if (addLinkToggle) {
|
||||||
|
addLinkToggle.classList.remove('active');
|
||||||
|
addLinkToggle.blur();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Clear search and reset if needed
|
// Clear search and reset if needed
|
||||||
if (searchInput.value.trim()) {
|
if (searchInput.value.trim()) {
|
||||||
@@ -105,7 +358,10 @@ function setupMobileSearch() {
|
|||||||
// Hide add link if visible
|
// Hide add link if visible
|
||||||
if (addLinkWrapper && addLinkWrapper.classList.contains('show')) {
|
if (addLinkWrapper && addLinkWrapper.classList.contains('show')) {
|
||||||
addLinkWrapper.classList.remove('show');
|
addLinkWrapper.classList.remove('show');
|
||||||
if (addLinkToggle) addLinkToggle.classList.remove('active');
|
if (addLinkToggle) {
|
||||||
|
addLinkToggle.classList.remove('active');
|
||||||
|
addLinkToggle.blur();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Show search
|
// Show search
|
||||||
searchWrapper.classList.add('show');
|
searchWrapper.classList.add('show');
|
||||||
@@ -129,6 +385,13 @@ function setupMobileAddLink() {
|
|||||||
// Hide add link
|
// Hide add link
|
||||||
addLinkWrapper.classList.remove('show');
|
addLinkWrapper.classList.remove('show');
|
||||||
addLinkToggle.classList.remove('active');
|
addLinkToggle.classList.remove('active');
|
||||||
|
// Force remove active state on mobile - use requestAnimationFrame to ensure it happens after click processing
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
addLinkToggle.blur();
|
||||||
|
if (document.activeElement === addLinkToggle) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
// Clear input if needed
|
// Clear input if needed
|
||||||
if (linkInput.value.trim()) {
|
if (linkInput.value.trim()) {
|
||||||
linkInput.value = '';
|
linkInput.value = '';
|
||||||
@@ -137,7 +400,10 @@ function setupMobileAddLink() {
|
|||||||
// Hide search if visible
|
// Hide search if visible
|
||||||
if (searchWrapper && searchWrapper.classList.contains('show')) {
|
if (searchWrapper && searchWrapper.classList.contains('show')) {
|
||||||
searchWrapper.classList.remove('show');
|
searchWrapper.classList.remove('show');
|
||||||
if (searchToggle) searchToggle.classList.remove('active');
|
if (searchToggle) {
|
||||||
|
searchToggle.classList.remove('active');
|
||||||
|
searchToggle.blur();
|
||||||
|
}
|
||||||
if (searchInput.value.trim()) {
|
if (searchInput.value.trim()) {
|
||||||
searchInput.value = '';
|
searchInput.value = '';
|
||||||
loadLinks();
|
loadLinks();
|
||||||
@@ -161,8 +427,18 @@ function setupLayoutToggle() {
|
|||||||
// Toggle dropdown on button click
|
// Toggle dropdown on button click
|
||||||
layoutToggle.addEventListener('click', (e) => {
|
layoutToggle.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
const wasVisible = layoutDropdown.classList.contains('show');
|
||||||
layoutDropdown.classList.toggle('show');
|
layoutDropdown.classList.toggle('show');
|
||||||
layoutToggle.classList.toggle('active');
|
layoutToggle.classList.toggle('active');
|
||||||
|
// Blur button when closing dropdown to remove focus/active state
|
||||||
|
if (wasVisible) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
layoutToggle.blur();
|
||||||
|
if (document.activeElement === layoutToggle) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
// Close dropdown when clicking outside
|
||||||
@@ -170,6 +446,13 @@ function setupLayoutToggle() {
|
|||||||
if (!layoutToggle.contains(e.target) && !layoutDropdown.contains(e.target)) {
|
if (!layoutToggle.contains(e.target) && !layoutDropdown.contains(e.target)) {
|
||||||
layoutDropdown.classList.remove('show');
|
layoutDropdown.classList.remove('show');
|
||||||
layoutToggle.classList.remove('active');
|
layoutToggle.classList.remove('active');
|
||||||
|
// Force remove active state on mobile - use requestAnimationFrame to ensure it happens after click processing
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
layoutToggle.blur();
|
||||||
|
if (document.activeElement === layoutToggle) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -276,15 +559,17 @@ async function handleAddLink(e) {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({ url })
|
body: JSON.stringify({ url })
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.error || 'Failed to add link');
|
const handled = await handleApiError(response, 'Failed to add link');
|
||||||
|
if (handled) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
// Ensure archived and listIds properties exist
|
// Ensure archived and listIds properties exist
|
||||||
data.archived = data.archived || false;
|
data.archived = data.archived || false;
|
||||||
data.listIds = data.listIds || [];
|
data.listIds = data.listIds || [];
|
||||||
@@ -297,6 +582,7 @@ async function handleAddLink(e) {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({ listIds: selectedListFilters })
|
body: JSON.stringify({ listIds: selectedListFilters })
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -369,8 +655,7 @@ function handleSearch(e) {
|
|||||||
|
|
||||||
// Handle archive toggle
|
// Handle archive toggle
|
||||||
function handleToggleArchive() {
|
function handleToggleArchive() {
|
||||||
showArchived = !showArchived;
|
showArchived = archiveToggle.checked;
|
||||||
archiveToggle.classList.toggle('active', showArchived);
|
|
||||||
|
|
||||||
// Re-filter and display links
|
// Re-filter and display links
|
||||||
const query = searchInput.value.trim();
|
const query = searchInput.value.trim();
|
||||||
@@ -424,6 +709,9 @@ function displayLinks(links) {
|
|||||||
handleAddToLists(linkId);
|
handleAddToLists(linkId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update UI based on auth status after rendering
|
||||||
|
updateUIBasedOnAuth();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create link card HTML
|
// Create link card HTML
|
||||||
@@ -566,10 +854,14 @@ async function handleArchiveLink(id) {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({ archived: newArchivedStatus })
|
body: JSON.stringify({ archived: newArchivedStatus })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to archive link');
|
if (!response.ok) {
|
||||||
|
const handled = await handleApiError(response, 'Failed to archive link');
|
||||||
|
if (handled) return;
|
||||||
|
}
|
||||||
|
|
||||||
// Update local array
|
// Update local array
|
||||||
link.archived = newArchivedStatus;
|
link.archived = newArchivedStatus;
|
||||||
@@ -598,10 +890,14 @@ async function handleDeleteLink(id) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/${id}`, {
|
const response = await fetch(`${API_BASE}/${id}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE',
|
||||||
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to delete link');
|
if (!response.ok) {
|
||||||
|
const handled = await handleApiError(response, 'Failed to delete link');
|
||||||
|
if (handled) return;
|
||||||
|
}
|
||||||
|
|
||||||
// Remove from local array
|
// Remove from local array
|
||||||
allLinks = allLinks.filter(link => link.id !== id);
|
allLinks = allLinks.filter(link => link.id !== id);
|
||||||
@@ -665,11 +961,27 @@ async function loadLists() {
|
|||||||
// Setup lists management
|
// Setup lists management
|
||||||
function setupListsManagement() {
|
function setupListsManagement() {
|
||||||
// Toggle filter section visibility
|
// Toggle filter section visibility
|
||||||
listsToggle.addEventListener('click', () => {
|
listsToggle.addEventListener('click', (e) => {
|
||||||
const isVisible = listFilterWrapper.classList.contains('show');
|
const isVisible = listFilterWrapper.classList.contains('show');
|
||||||
if (isVisible) {
|
if (isVisible) {
|
||||||
listFilterWrapper.classList.remove('show');
|
listFilterWrapper.classList.remove('show');
|
||||||
listsToggle.classList.remove('active');
|
listsToggle.classList.remove('active');
|
||||||
|
// Force remove active state on mobile - use double requestAnimationFrame for listsToggle
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
listsToggle.blur();
|
||||||
|
if (document.activeElement === listsToggle) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
// Second frame to ensure it's fully cleared
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
listsToggle.blur();
|
||||||
|
// Force style recalculation
|
||||||
|
listsToggle.style.pointerEvents = 'none';
|
||||||
|
setTimeout(() => {
|
||||||
|
listsToggle.style.pointerEvents = '';
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
// Reset filters when hiding the section
|
// Reset filters when hiding the section
|
||||||
selectedListFilters = [];
|
selectedListFilters = [];
|
||||||
updateUrlForListFilter();
|
updateUrlForListFilter();
|
||||||
@@ -817,15 +1129,17 @@ async function handleCreateList() {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({ name })
|
body: JSON.stringify({ name })
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.error || 'Failed to create list');
|
const handled = await handleApiError(response, 'Failed to create list');
|
||||||
|
if (handled) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
allLists.push(data);
|
allLists.push(data);
|
||||||
newListName.value = '';
|
newListName.value = '';
|
||||||
renderListsList();
|
renderListsList();
|
||||||
@@ -856,15 +1170,17 @@ async function handleUpdateList(listId) {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({ name })
|
body: JSON.stringify({ name })
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.error || 'Failed to update list');
|
const handled = await handleApiError(response, 'Failed to update list');
|
||||||
|
if (handled) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
const listIndex = allLists.findIndex(l => l.id === listId);
|
const listIndex = allLists.findIndex(l => l.id === listId);
|
||||||
if (listIndex !== -1) {
|
if (listIndex !== -1) {
|
||||||
allLists[listIndex] = data;
|
allLists[listIndex] = data;
|
||||||
@@ -899,15 +1215,17 @@ async function handleTogglePublic(listId) {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({ public: newPublicStatus })
|
body: JSON.stringify({ public: newPublicStatus })
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.error || 'Failed to update list public status');
|
const handled = await handleApiError(response, 'Failed to update list public status');
|
||||||
|
if (handled) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
// Update local array
|
// Update local array
|
||||||
const listIndex = allLists.findIndex(l => l.id === listId);
|
const listIndex = allLists.findIndex(l => l.id === listId);
|
||||||
if (listIndex !== -1) {
|
if (listIndex !== -1) {
|
||||||
@@ -930,10 +1248,14 @@ async function handleDeleteList(listId) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${LISTS_API_BASE}/${listId}`, {
|
const response = await fetch(`${LISTS_API_BASE}/${listId}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE',
|
||||||
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to delete list');
|
if (!response.ok) {
|
||||||
|
const handled = await handleApiError(response, 'Failed to delete list');
|
||||||
|
if (handled) return;
|
||||||
|
}
|
||||||
|
|
||||||
allLists = allLists.filter(list => list.id !== listId);
|
allLists = allLists.filter(list => list.id !== listId);
|
||||||
selectedListFilters = selectedListFilters.filter(id => id !== listId);
|
selectedListFilters = selectedListFilters.filter(id => id !== listId);
|
||||||
@@ -992,15 +1314,17 @@ async function handleCheckboxChange(linkId) {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({ listIds: selectedListIds })
|
body: JSON.stringify({ listIds: selectedListIds })
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to update link lists');
|
const handled = await handleApiError(response, 'Failed to update link lists');
|
||||||
|
if (handled) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
// Update local array
|
// Update local array
|
||||||
const linkIndex = allLinks.findIndex(l => l.id === linkId);
|
const linkIndex = allLinks.findIndex(l => l.id === linkId);
|
||||||
if (linkIndex !== -1) {
|
if (linkIndex !== -1) {
|
||||||
@@ -1022,6 +1346,41 @@ async function handleCheckboxChange(linkId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset all filters and load all links
|
||||||
|
function resetAllFilters() {
|
||||||
|
// Clear search
|
||||||
|
searchInput.value = '';
|
||||||
|
|
||||||
|
// Reset archive toggle
|
||||||
|
showArchived = false;
|
||||||
|
archiveToggle.checked = false;
|
||||||
|
|
||||||
|
// Clear list filters
|
||||||
|
selectedListFilters = [];
|
||||||
|
updateUrlForListFilter();
|
||||||
|
if (listFilterWrapper.classList.contains('show')) {
|
||||||
|
updateListFilterChips();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide search and add link wrappers if visible
|
||||||
|
searchWrapper.classList.remove('show');
|
||||||
|
searchToggle.classList.remove('active');
|
||||||
|
addLinkWrapper.classList.remove('show');
|
||||||
|
addLinkToggle.classList.remove('active');
|
||||||
|
|
||||||
|
// Load all links
|
||||||
|
loadLinks();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup logo click to reset filters
|
||||||
|
function setupLogoClick() {
|
||||||
|
const appLogo = document.getElementById('appLogo');
|
||||||
|
if (appLogo) {
|
||||||
|
appLogo.style.cursor = 'pointer';
|
||||||
|
appLogo.addEventListener('click', resetAllFilters);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Setup list filtering
|
// Setup list filtering
|
||||||
function setupListFiltering() {
|
function setupListFiltering() {
|
||||||
clearListFilters.addEventListener('click', () => {
|
clearListFilters.addEventListener('click', () => {
|
||||||
@@ -1081,7 +1440,14 @@ function updateUrlForListFilter() {
|
|||||||
|
|
||||||
// Update list filter chips
|
// Update list filter chips
|
||||||
function updateListFilterChips() {
|
function updateListFilterChips() {
|
||||||
if (allLists.length === 0) {
|
// Filter lists based on authentication status
|
||||||
|
let listsToShow = allLists;
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
// Only show public lists when not logged in
|
||||||
|
listsToShow = allLists.filter(list => list.public === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listsToShow.length === 0) {
|
||||||
// Don't hide the wrapper if it's already shown, just show empty state
|
// Don't hide the wrapper if it's already shown, just show empty state
|
||||||
listFilterChips.innerHTML = '<p class="empty-lists-message" style="padding: 0.5rem 0; color: var(--text-muted); font-size: 0.875rem;">No lists available. Click Edit to create lists.</p>';
|
listFilterChips.innerHTML = '<p class="empty-lists-message" style="padding: 0.5rem 0; color: var(--text-muted); font-size: 0.875rem;">No lists available. Click Edit to create lists.</p>';
|
||||||
return;
|
return;
|
||||||
@@ -1092,7 +1458,7 @@ function updateListFilterChips() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
listFilterChips.innerHTML = allLists.map(list => {
|
listFilterChips.innerHTML = listsToShow.map(list => {
|
||||||
const isSelected = selectedListFilters.includes(list.id);
|
const isSelected = selectedListFilters.includes(list.id);
|
||||||
return `
|
return `
|
||||||
<button class="list-filter-chip ${isSelected ? 'active' : ''}" data-id="${list.id}">
|
<button class="list-filter-chip ${isSelected ? 'active' : ''}" data-id="${list.id}">
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
<div class="header-container">
|
<div class="header-container">
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<h1 class="app-title">
|
<h1 class="app-title" id="appLogo">
|
||||||
<span class="title-text">🔗 Links</span>
|
<span class="title-text"><img src="/icon-192.png" alt="LinkDing" class="title-icon"></span>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
@@ -39,13 +39,6 @@
|
|||||||
<line x1="2" y1="18" x2="2.01" y2="18"></line>
|
<line x1="2" y1="18" x2="2.01" y2="18"></line>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button id="archiveToggle" class="archive-toggle-btn" title="Toggle archived links">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
|
||||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
|
||||||
<line x1="12" y1="22.08" x2="12" y2="12"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div class="layout-toggle-wrapper">
|
<div class="layout-toggle-wrapper">
|
||||||
<button id="layoutToggle" class="layout-toggle-btn" title="Change layout">
|
<button id="layoutToggle" class="layout-toggle-btn" title="Change layout">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@@ -82,6 +75,22 @@
|
|||||||
<path d="m21 21-4.35-4.35"></path>
|
<path d="m21 21-4.35-4.35"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="loginToggle" class="login-toggle-btn" title="Login">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="12" cy="7" r="4"></circle>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="userInfo" class="user-info" style="display: none;">
|
||||||
|
<span id="usernameDisplay"></span>
|
||||||
|
<button id="logoutBtn" class="logout-btn" title="Logout">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||||
|
<polyline points="16 17 21 12 16 7"></polyline>
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-fields-container">
|
<div class="header-fields-container">
|
||||||
@@ -126,6 +135,11 @@
|
|||||||
<div class="list-filter-header">
|
<div class="list-filter-header">
|
||||||
<span class="list-filter-label"><b>Lists</b></span>
|
<span class="list-filter-label"><b>Lists</b></span>
|
||||||
<div class="list-filter-actions">
|
<div class="list-filter-actions">
|
||||||
|
<label class="archive-toggle-switch" title="Show archived links">
|
||||||
|
<input type="checkbox" id="archiveToggle">
|
||||||
|
<span class="archive-toggle-slider"></span>
|
||||||
|
<span class="archive-toggle-label">Archive</span>
|
||||||
|
</label>
|
||||||
<button id="editListsBtn" class="edit-lists-btn" title="Manage lists">
|
<button id="editListsBtn" class="edit-lists-btn" title="Manage lists">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||||
@@ -144,6 +158,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- Login Modal -->
|
||||||
|
<div id="loginModal" class="login-modal">
|
||||||
|
<div class="login-modal-content">
|
||||||
|
<div class="login-modal-header">
|
||||||
|
<h2>Login</h2>
|
||||||
|
<button id="closeLoginModal" class="close-modal-btn">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="login-modal-body">
|
||||||
|
<form id="loginForm" class="login-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="loginUsername">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="loginUsername"
|
||||||
|
placeholder="Enter username"
|
||||||
|
required
|
||||||
|
autocomplete="username"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="loginPassword">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="loginPassword"
|
||||||
|
placeholder="Enter password"
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div id="loginError" class="login-error" style="display: none;"></div>
|
||||||
|
<button type="submit" id="loginSubmitBtn" class="btn-login">
|
||||||
|
<span class="btn-text">Login</span>
|
||||||
|
<span class="btn-loader" style="display: none;">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Lists Management Modal -->
|
<!-- Lists Management Modal -->
|
||||||
<div id="listsModal" class="lists-modal">
|
<div id="listsModal" class="lists-modal">
|
||||||
<div class="lists-modal-content">
|
<div class="lists-modal-content">
|
||||||
|
|||||||
@@ -87,11 +87,20 @@ body {
|
|||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title:hover {
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-icon {
|
.title-icon {
|
||||||
font-size: 1.5rem;
|
width: 36px;
|
||||||
filter: drop-shadow(0 2px 4px rgba(99, 102, 241, 0.3));
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-text {
|
.title-text {
|
||||||
@@ -110,40 +119,6 @@ body {
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Archive Toggle Button */
|
|
||||||
.archive-toggle-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.archive-toggle-btn:hover {
|
|
||||||
background: var(--surface-light);
|
|
||||||
border-color: var(--secondary-color);
|
|
||||||
color: var(--secondary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.archive-toggle-btn.active {
|
|
||||||
background: rgba(139, 92, 246, 0.1);
|
|
||||||
border-color: var(--secondary-color);
|
|
||||||
color: var(--secondary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.archive-toggle-btn svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Layout Toggle Button */
|
/* Layout Toggle Button */
|
||||||
.layout-toggle-wrapper {
|
.layout-toggle-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -439,6 +414,74 @@ body {
|
|||||||
height: 20px;
|
height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Login Toggle Button */
|
||||||
|
.login-toggle-btn {
|
||||||
|
display: flex;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-toggle-btn:hover {
|
||||||
|
background: var(--surface-light);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-toggle-btn svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User Info */
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--surface-light);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info span {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
display: flex;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Add Link Toggle Button */
|
/* Add Link Toggle Button */
|
||||||
.add-link-toggle-btn {
|
.add-link-toggle-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1043,6 +1086,60 @@ body {
|
|||||||
.header-right {
|
.header-right {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Remove focus outline and active state persistence on mobile nav buttons */
|
||||||
|
.search-toggle-btn,
|
||||||
|
.add-link-toggle-btn,
|
||||||
|
.lists-toggle-btn,
|
||||||
|
.layout-toggle-btn {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-toggle-btn:focus,
|
||||||
|
.add-link-toggle-btn:focus,
|
||||||
|
.lists-toggle-btn:focus,
|
||||||
|
.layout-toggle-btn:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent active state from persisting when button is not in .active class */
|
||||||
|
.search-toggle-btn:active:not(.active),
|
||||||
|
.add-link-toggle-btn:active:not(.active),
|
||||||
|
.lists-toggle-btn:active:not(.active),
|
||||||
|
.layout-toggle-btn:active:not(.active) {
|
||||||
|
background: transparent !important;
|
||||||
|
border-color: var(--border) !important;
|
||||||
|
color: var(--text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure buttons without .active class return to default state */
|
||||||
|
.search-toggle-btn:not(.active),
|
||||||
|
.add-link-toggle-btn:not(.active),
|
||||||
|
.lists-toggle-btn:not(.active),
|
||||||
|
.layout-toggle-btn:not(.active) {
|
||||||
|
background: transparent !important;
|
||||||
|
border-color: var(--border) !important;
|
||||||
|
color: var(--text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Extra specificity for lists-toggle-btn to ensure it resets */
|
||||||
|
.lists-toggle-btn:not(.active):not(:hover) {
|
||||||
|
background: transparent !important;
|
||||||
|
border-color: var(--border) !important;
|
||||||
|
color: var(--text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide username text on mobile, show only logout icon */
|
||||||
|
.user-info span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
padding: 0.5rem;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.add-link-toggle-btn {
|
.add-link-toggle-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1168,11 +1265,6 @@ body {
|
|||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-toggle-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-to-top-btn {
|
.scroll-to-top-btn {
|
||||||
bottom: 1.5rem;
|
bottom: 1.5rem;
|
||||||
right: 1.5rem;
|
right: 1.5rem;
|
||||||
@@ -1252,6 +1344,70 @@ body {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Archive Toggle Switch */
|
||||||
|
.archive-toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-toggle-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-toggle-switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-toggle-slider {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--border);
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-toggle-slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background-color: white;
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-toggle-switch input:checked + .archive-toggle-slider {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-toggle-switch input:checked + .archive-toggle-slider:before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-toggle-switch:hover .archive-toggle-slider {
|
||||||
|
background-color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-toggle-switch input:checked:hover + .archive-toggle-slider {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
.edit-lists-btn {
|
.edit-lists-btn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -1582,6 +1738,149 @@ body {
|
|||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Login Modal */
|
||||||
|
.login-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-modal.show {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-modal-content {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 1rem;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 400px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 20px 60px var(--shadow-lg);
|
||||||
|
transform: scale(0.9);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-modal.show .login-modal-content {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.8rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
background: var(--background);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid var(--error);
|
||||||
|
color: var(--error);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 16px rgba(99, 102, 241, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login .btn-loader {
|
||||||
|
display: none;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* List Selection Overlay */
|
/* List Selection Overlay */
|
||||||
.list-selection-overlay {
|
.list-selection-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -1626,7 +1925,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 1.5rem;
|
padding: 0.8rem;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1778,3 +2077,10 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide page title on very small screens */
|
||||||
|
@media (max-width: 357px) {
|
||||||
|
.app-title {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_NAME = 'linkding-v2';
|
const CACHE_NAME = 'linkding-v2.0.1';
|
||||||
const urlsToCache = [
|
const urlsToCache = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
|
|||||||
640
server.js
640
server.js
@@ -1,9 +1,17 @@
|
|||||||
|
require('dotenv').config();
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
|
const session = require('express-session');
|
||||||
|
const passport = require('passport');
|
||||||
|
const LdapStrategy = require('passport-ldapauth').Strategy;
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const cheerio = require('cheerio');
|
const cheerio = require('cheerio');
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
const db = require('./models');
|
||||||
|
const MigrationRunner = require('./migrations/runner');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
|
||||||
// Lazy load puppeteer (only if needed)
|
// Lazy load puppeteer (only if needed)
|
||||||
let puppeteer = null;
|
let puppeteer = null;
|
||||||
@@ -66,59 +74,288 @@ const PORT = process.env.PORT || 3000;
|
|||||||
const DATA_FILE = path.join(__dirname, 'data', 'links.json');
|
const DATA_FILE = path.join(__dirname, 'data', 'links.json');
|
||||||
const LISTS_FILE = path.join(__dirname, 'data', 'lists.json');
|
const LISTS_FILE = path.join(__dirname, 'data', 'lists.json');
|
||||||
|
|
||||||
|
// Trust proxy - required when behind reverse proxy (Traefik)
|
||||||
|
// This allows Express to trust X-Forwarded-* headers
|
||||||
|
app.set('trust proxy', process.env.TRUST_PROXY !== 'false'); // Default to true, set to 'false' to disable
|
||||||
|
|
||||||
|
// Session configuration
|
||||||
|
const isSecure = process.env.COOKIE_SECURE === 'true' ||
|
||||||
|
(process.env.COOKIE_SECURE !== 'false' && process.env.NODE_ENV === 'production');
|
||||||
|
|
||||||
|
app.use(session({
|
||||||
|
secret: process.env.SESSION_SECRET || 'your-secret-key-change-this-in-production',
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
name: process.env.SESSION_NAME || 'connect.sid', // Custom session name to avoid conflicts
|
||||||
|
cookie: {
|
||||||
|
secure: isSecure, // Use secure cookies when behind HTTPS proxy
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||||
|
sameSite: process.env.COOKIE_SAMESITE || (isSecure ? 'none' : 'lax'), // 'none' for cross-site, 'lax' for same-site
|
||||||
|
domain: process.env.COOKIE_DOMAIN || undefined, // Set if cookies need to be shared across subdomains
|
||||||
|
path: process.env.COOKIE_PATH || '/' // Cookie path
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Initialize Passport
|
||||||
|
app.use(passport.initialize());
|
||||||
|
app.use(passport.session());
|
||||||
|
|
||||||
|
// Configure LDAP Strategy
|
||||||
|
// Combine base DN with additional users DN if provided
|
||||||
|
const baseDN = process.env.LDAP_BASE_DN;
|
||||||
|
const additionalUsersDN = process.env.LDAP_ADDITIONAL_USERS_DN || '';
|
||||||
|
const searchBase = additionalUsersDN && baseDN
|
||||||
|
? `${additionalUsersDN},${baseDN}`
|
||||||
|
: baseDN;
|
||||||
|
|
||||||
|
const searchAttributes = [];
|
||||||
|
if (process.env.LDAP_ATTRIBUTE_USERNAME) {
|
||||||
|
searchAttributes.push(process.env.LDAP_ATTRIBUTE_USERNAME);
|
||||||
|
}
|
||||||
|
if (process.env.LDAP_ATTRIBUTE_MAIL) {
|
||||||
|
searchAttributes.push(process.env.LDAP_ATTRIBUTE_MAIL);
|
||||||
|
}
|
||||||
|
if (process.env.LDAP_ATTRIBUTE_DISTINGUISHED_NAME) {
|
||||||
|
searchAttributes.push(process.env.LDAP_ATTRIBUTE_DISTINGUISHED_NAME);
|
||||||
|
}
|
||||||
|
if (process.env.LDAP_ATTRIBUTE_MEMBER_OF) {
|
||||||
|
searchAttributes.push(process.env.LDAP_ATTRIBUTE_MEMBER_OF);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ldapOptions = {
|
||||||
|
server: {
|
||||||
|
url: process.env.LDAP_ADDRESS,
|
||||||
|
bindDN: process.env.LDAP_USER,
|
||||||
|
bindCredentials: process.env.LDAP_PASSWORD,
|
||||||
|
searchBase: searchBase,
|
||||||
|
searchFilter: process.env.LDAP_USERS_FILTER,
|
||||||
|
searchAttributes: searchAttributes.length > 0 ? searchAttributes : undefined,
|
||||||
|
timeout: process.env.LDAP_TIMEOUT ? parseInt(process.env.LDAP_TIMEOUT) : undefined,
|
||||||
|
connectTimeout: process.env.LDAP_TIMEOUT ? parseInt(process.env.LDAP_TIMEOUT) : undefined,
|
||||||
|
tlsOptions: {
|
||||||
|
rejectUnauthorized: process.env.LDAP_TLS_SKIP_VERIFY !== 'true',
|
||||||
|
servername: process.env.LDAP_TLS_SERVER_NAME || undefined
|
||||||
|
}
|
||||||
|
},
|
||||||
|
usernameField: 'username',
|
||||||
|
passwordField: 'password'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Replace {username} placeholder in search filter
|
||||||
|
if (ldapOptions.server.searchFilter && ldapOptions.server.searchFilter.includes('{{username}}')) {
|
||||||
|
// Keep as is, passport-ldapauth will replace it
|
||||||
|
} else if (ldapOptions.server.searchFilter && ldapOptions.server.searchFilter.includes('{username_attribute}')) {
|
||||||
|
// Replace with actual attribute name
|
||||||
|
const usernameAttr = process.env.LDAP_ATTRIBUTE_USERNAME;
|
||||||
|
if (usernameAttr) {
|
||||||
|
ldapOptions.server.searchFilter = ldapOptions.server.searchFilter.replace('{username_attribute}', usernameAttr);
|
||||||
|
ldapOptions.server.searchFilter = ldapOptions.server.searchFilter.replace('{input}', '{{username}}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
passport.use(new LdapStrategy(ldapOptions, (user, done) => {
|
||||||
|
// User object contains LDAP user data
|
||||||
|
const usernameAttr = process.env.LDAP_ATTRIBUTE_USERNAME;
|
||||||
|
const mailAttr = process.env.LDAP_ATTRIBUTE_MAIL;
|
||||||
|
const dnAttr = process.env.LDAP_ATTRIBUTE_DISTINGUISHED_NAME;
|
||||||
|
|
||||||
|
return done(null, {
|
||||||
|
id: usernameAttr ? user[usernameAttr] : user.uid,
|
||||||
|
username: usernameAttr ? user[usernameAttr] : user.uid,
|
||||||
|
email: mailAttr ? user[mailAttr] : user.mail,
|
||||||
|
dn: dnAttr ? user[dnAttr] : user.dn
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Serialize user for session
|
||||||
|
passport.serializeUser((user, done) => {
|
||||||
|
done(null, user);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deserialize user from session
|
||||||
|
passport.deserializeUser((user, done) => {
|
||||||
|
done(null, user);
|
||||||
|
});
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(cors());
|
app.use(cors({
|
||||||
|
origin: true,
|
||||||
|
credentials: true
|
||||||
|
}));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.static('public'));
|
app.use(express.static('public'));
|
||||||
|
|
||||||
// Ensure data directory exists
|
// Authentication middleware
|
||||||
async function ensureDataDir() {
|
function isAuthenticated(req, res, next) {
|
||||||
const dataDir = path.dirname(DATA_FILE);
|
if (req.isAuthenticated()) {
|
||||||
try {
|
return next();
|
||||||
await fs.access(dataDir);
|
|
||||||
} catch {
|
|
||||||
await fs.mkdir(dataDir, { recursive: true });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await fs.access(DATA_FILE);
|
|
||||||
} catch {
|
|
||||||
await fs.writeFile(DATA_FILE, JSON.stringify([]));
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await fs.access(LISTS_FILE);
|
|
||||||
} catch {
|
|
||||||
await fs.writeFile(LISTS_FILE, JSON.stringify([]));
|
|
||||||
}
|
}
|
||||||
|
res.status(401).json({ error: 'Authentication required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read links from file
|
// Database initialization and migration
|
||||||
async function readLinks() {
|
async function initializeDatabase() {
|
||||||
try {
|
try {
|
||||||
const data = await fs.readFile(DATA_FILE, 'utf8');
|
// Test database connection
|
||||||
return JSON.parse(data);
|
await db.sequelize.authenticate();
|
||||||
|
console.log('Database connection established successfully.');
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
const migrationRunner = new MigrationRunner(db.sequelize);
|
||||||
|
await migrationRunner.runMigrations();
|
||||||
|
|
||||||
|
// Migrate JSON files if they exist
|
||||||
|
await migrateJsonFiles();
|
||||||
|
|
||||||
|
console.log('Database initialization completed.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return [];
|
console.error('Database initialization failed:', error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write links to file
|
// Migrate JSON files to database
|
||||||
async function writeLinks(links) {
|
async function migrateJsonFiles() {
|
||||||
await fs.writeFile(DATA_FILE, JSON.stringify(links, null, 2));
|
const linksFile = DATA_FILE;
|
||||||
}
|
const listsFile = LISTS_FILE;
|
||||||
|
const linksBackup = linksFile + '.bak';
|
||||||
|
const listsBackup = listsFile + '.bak';
|
||||||
|
|
||||||
// Read lists from file
|
// Check if files have already been migrated
|
||||||
async function readLists() {
|
let linksAlreadyMigrated = false;
|
||||||
|
let listsAlreadyMigrated = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await fs.readFile(LISTS_FILE, 'utf8');
|
await fs.access(linksBackup);
|
||||||
return JSON.parse(data);
|
linksAlreadyMigrated = true;
|
||||||
} catch (error) {
|
} catch {
|
||||||
return [];
|
// Not migrated yet
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(listsBackup);
|
||||||
|
listsAlreadyMigrated = true;
|
||||||
|
} catch {
|
||||||
|
// Not migrated yet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Migrate lists first (so we can create relationships)
|
||||||
|
const listIdMap = new Map(); // Map old ID -> new UUID
|
||||||
|
|
||||||
|
if (!listsAlreadyMigrated) {
|
||||||
|
try {
|
||||||
|
await fs.access(listsFile);
|
||||||
|
const listsData = JSON.parse(await fs.readFile(listsFile, 'utf8'));
|
||||||
|
|
||||||
|
if (Array.isArray(listsData) && listsData.length > 0) {
|
||||||
|
console.log(`Migrating ${listsData.length} lists from JSON file...`);
|
||||||
|
|
||||||
|
for (const list of listsData) {
|
||||||
|
const newId = uuidv4();
|
||||||
|
listIdMap.set(list.id, newId);
|
||||||
|
|
||||||
|
await db.List.create({
|
||||||
|
id: newId,
|
||||||
|
name: list.name,
|
||||||
|
created_at: list.createdAt ? new Date(list.createdAt) : new Date(),
|
||||||
|
created_by: null, // No user info in JSON
|
||||||
|
public: list.public || false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename file to backup
|
||||||
|
await fs.rename(listsFile, listsBackup);
|
||||||
|
console.log('Lists migration completed.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'ENOENT') {
|
||||||
|
console.error('Error migrating lists:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Migrate links and set up relationships
|
||||||
|
if (!linksAlreadyMigrated) {
|
||||||
|
try {
|
||||||
|
await fs.access(linksFile);
|
||||||
|
const linksData = JSON.parse(await fs.readFile(linksFile, 'utf8'));
|
||||||
|
|
||||||
|
if (Array.isArray(linksData) && linksData.length > 0) {
|
||||||
|
console.log(`Migrating ${linksData.length} links from JSON file...`);
|
||||||
|
|
||||||
|
for (const link of linksData) {
|
||||||
|
// Create link
|
||||||
|
const linkRecord = await db.Link.create({
|
||||||
|
id: uuidv4(),
|
||||||
|
url: link.url,
|
||||||
|
title: link.title || null,
|
||||||
|
description: link.description || null,
|
||||||
|
image: link.image || null,
|
||||||
|
created_at: link.createdAt ? new Date(link.createdAt) : new Date(),
|
||||||
|
created_by: null, // No user info in JSON
|
||||||
|
archived: link.archived || false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create relationships if listIds exist
|
||||||
|
if (link.listIds && Array.isArray(link.listIds) && link.listIds.length > 0) {
|
||||||
|
const listRecords = [];
|
||||||
|
for (const oldListId of link.listIds) {
|
||||||
|
const newListId = listIdMap.get(oldListId);
|
||||||
|
if (newListId) {
|
||||||
|
const listRecord = await db.List.findByPk(newListId);
|
||||||
|
if (listRecord) {
|
||||||
|
listRecords.push(listRecord);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (listRecords.length > 0) {
|
||||||
|
await linkRecord.setLists(listRecords);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename file to backup
|
||||||
|
await fs.rename(linksFile, linksBackup);
|
||||||
|
console.log('Links migration completed.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'ENOENT') {
|
||||||
|
console.error('Error migrating links:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write lists to file
|
// Helper function to format link for API response
|
||||||
async function writeLists(lists) {
|
function formatLink(link) {
|
||||||
await fs.writeFile(LISTS_FILE, JSON.stringify(lists, null, 2));
|
const formatted = {
|
||||||
|
id: link.id,
|
||||||
|
url: link.url,
|
||||||
|
title: link.title,
|
||||||
|
description: link.description,
|
||||||
|
image: link.image,
|
||||||
|
createdAt: link.created_at,
|
||||||
|
createdBy: link.created_by,
|
||||||
|
modifiedAt: link.modified_at,
|
||||||
|
modifiedBy: link.modified_by,
|
||||||
|
archived: link.archived || false,
|
||||||
|
listIds: link.lists ? link.lists.map(list => list.id) : []
|
||||||
|
};
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to format list for API response
|
||||||
|
function formatList(list) {
|
||||||
|
return {
|
||||||
|
id: list.id,
|
||||||
|
name: list.name,
|
||||||
|
createdAt: list.created_at,
|
||||||
|
createdBy: list.created_by,
|
||||||
|
modifiedAt: list.modified_at,
|
||||||
|
modifiedBy: list.modified_by,
|
||||||
|
public: list.public || false
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract metadata using Puppeteer (for JavaScript-heavy sites)
|
// Extract metadata using Puppeteer (for JavaScript-heavy sites)
|
||||||
@@ -589,14 +826,89 @@ async function extractMetadata(url) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authentication Routes
|
||||||
|
|
||||||
|
// Check authentication status
|
||||||
|
app.get('/api/auth/status', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
authenticated: req.isAuthenticated(),
|
||||||
|
user: req.isAuthenticated() ? req.user : null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login endpoint
|
||||||
|
app.post('/api/auth/login', (req, res, next) => {
|
||||||
|
passport.authenticate('ldapauth', (err, user, info) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('LDAP authentication error:', err);
|
||||||
|
return res.status(500).json({ error: 'Authentication failed', details: err.message });
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
req.logIn(user, (loginErr) => {
|
||||||
|
if (loginErr) {
|
||||||
|
return res.status(500).json({ error: 'Login failed', details: loginErr.message });
|
||||||
|
}
|
||||||
|
return res.json({
|
||||||
|
authenticated: true,
|
||||||
|
user: user
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logout endpoint
|
||||||
|
app.post('/api/auth/logout', (req, res) => {
|
||||||
|
req.logout((err) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'Logout failed' });
|
||||||
|
}
|
||||||
|
res.json({ authenticated: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// API Routes
|
// API Routes
|
||||||
|
|
||||||
// Get all links
|
// Get all links
|
||||||
app.get('/api/links', async (req, res) => {
|
app.get('/api/links', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const links = await readLinks();
|
let links;
|
||||||
res.json(links);
|
|
||||||
|
// If user is not authenticated, only show links in public lists
|
||||||
|
if (!req.isAuthenticated()) {
|
||||||
|
// Get all public lists
|
||||||
|
const publicLists = await db.List.findAll({
|
||||||
|
where: { public: true }
|
||||||
|
});
|
||||||
|
const publicListIds = publicLists.map(list => list.id);
|
||||||
|
|
||||||
|
// Get links that are in at least one public list
|
||||||
|
links = await db.Link.findAll({
|
||||||
|
include: [{
|
||||||
|
model: db.List,
|
||||||
|
as: 'lists',
|
||||||
|
where: { id: { [Op.in]: publicListIds } },
|
||||||
|
required: true,
|
||||||
|
attributes: ['id']
|
||||||
|
}],
|
||||||
|
order: [['created_at', 'DESC']]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Authenticated users see all links
|
||||||
|
links = await db.Link.findAll({
|
||||||
|
include: [{
|
||||||
|
model: db.List,
|
||||||
|
as: 'lists',
|
||||||
|
attributes: ['id']
|
||||||
|
}],
|
||||||
|
order: [['created_at', 'DESC']]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(links.map(formatLink));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error fetching links:', error);
|
||||||
res.status(500).json({ error: 'Failed to read links' });
|
res.status(500).json({ error: 'Failed to read links' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -605,27 +917,57 @@ app.get('/api/links', async (req, res) => {
|
|||||||
app.get('/api/links/search', async (req, res) => {
|
app.get('/api/links/search', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const query = req.query.q?.toLowerCase() || '';
|
const query = req.query.q?.toLowerCase() || '';
|
||||||
const links = await readLinks();
|
|
||||||
|
|
||||||
if (!query) {
|
const whereClause = {};
|
||||||
return res.json(links);
|
if (query) {
|
||||||
|
whereClause[Op.or] = [
|
||||||
|
{ title: { [Op.iLike]: `%${query}%` } },
|
||||||
|
{ description: { [Op.iLike]: `%${query}%` } },
|
||||||
|
{ url: { [Op.iLike]: `%${query}%` } }
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = links.filter(link => {
|
let links;
|
||||||
const titleMatch = link.title?.toLowerCase().includes(query);
|
|
||||||
const descMatch = link.description?.toLowerCase().includes(query);
|
|
||||||
const urlMatch = link.url?.toLowerCase().includes(query);
|
|
||||||
return titleMatch || descMatch || urlMatch;
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(filtered);
|
// If user is not authenticated, only show links in public lists
|
||||||
|
if (!req.isAuthenticated()) {
|
||||||
|
const publicLists = await db.List.findAll({
|
||||||
|
where: { public: true }
|
||||||
|
});
|
||||||
|
const publicListIds = publicLists.map(list => list.id);
|
||||||
|
|
||||||
|
links = await db.Link.findAll({
|
||||||
|
where: whereClause,
|
||||||
|
include: [{
|
||||||
|
model: db.List,
|
||||||
|
as: 'lists',
|
||||||
|
where: { id: { [Op.in]: publicListIds } },
|
||||||
|
required: true,
|
||||||
|
attributes: ['id']
|
||||||
|
}],
|
||||||
|
order: [['created_at', 'DESC']]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
links = await db.Link.findAll({
|
||||||
|
where: whereClause,
|
||||||
|
include: [{
|
||||||
|
model: db.List,
|
||||||
|
as: 'lists',
|
||||||
|
attributes: ['id']
|
||||||
|
}],
|
||||||
|
order: [['created_at', 'DESC']]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(links.map(formatLink));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error searching links:', error);
|
||||||
res.status(500).json({ error: 'Failed to search links' });
|
res.status(500).json({ error: 'Failed to search links' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add a new link
|
// Add a new link
|
||||||
app.post('/api/links', async (req, res) => {
|
app.post('/api/links', isAuthenticated, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { url } = req.body;
|
const { url } = req.body;
|
||||||
|
|
||||||
@@ -634,8 +976,7 @@ app.post('/api/links', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if link already exists
|
// Check if link already exists
|
||||||
const links = await readLinks();
|
const existingLink = await db.Link.findOne({ where: { url } });
|
||||||
const existingLink = links.find(link => link.url === url);
|
|
||||||
if (existingLink) {
|
if (existingLink) {
|
||||||
return res.status(409).json({ error: 'Link already exists' });
|
return res.status(409).json({ error: 'Link already exists' });
|
||||||
}
|
}
|
||||||
@@ -644,19 +985,19 @@ app.post('/api/links', async (req, res) => {
|
|||||||
const metadata = await extractMetadata(url);
|
const metadata = await extractMetadata(url);
|
||||||
|
|
||||||
// Create new link
|
// Create new link
|
||||||
const newLink = {
|
const newLink = await db.Link.create({
|
||||||
id: Date.now().toString(),
|
|
||||||
url: url,
|
url: url,
|
||||||
title: metadata.title,
|
title: metadata.title,
|
||||||
description: metadata.description,
|
description: metadata.description,
|
||||||
image: metadata.image,
|
image: metadata.image,
|
||||||
createdAt: new Date().toISOString()
|
created_by: req.user?.username || null,
|
||||||
};
|
archived: false
|
||||||
|
});
|
||||||
|
|
||||||
links.unshift(newLink); // Add to beginning
|
// Reload with associations to get listIds
|
||||||
await writeLinks(links);
|
await newLink.reload({ include: [{ model: db.List, as: 'lists', attributes: ['id'] }] });
|
||||||
|
|
||||||
res.status(201).json(newLink);
|
res.status(201).json(formatLink(newLink));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error adding link:', error);
|
console.error('Error adding link:', error);
|
||||||
res.status(500).json({ error: 'Failed to add link' });
|
res.status(500).json({ error: 'Failed to add link' });
|
||||||
@@ -664,7 +1005,7 @@ app.post('/api/links', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Archive/Unarchive a link
|
// Archive/Unarchive a link
|
||||||
app.patch('/api/links/:id/archive', async (req, res) => {
|
app.patch('/api/links/:id/archive', isAuthenticated, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { archived } = req.body;
|
const { archived } = req.body;
|
||||||
@@ -673,42 +1014,48 @@ app.patch('/api/links/:id/archive', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'archived must be a boolean' });
|
return res.status(400).json({ error: 'archived must be a boolean' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const links = await readLinks();
|
const link = await db.Link.findByPk(id, {
|
||||||
const linkIndex = links.findIndex(link => link.id === id);
|
include: [{ model: db.List, as: 'lists', attributes: ['id'] }]
|
||||||
|
});
|
||||||
|
|
||||||
if (linkIndex === -1) {
|
if (!link) {
|
||||||
return res.status(404).json({ error: 'Link not found' });
|
return res.status(404).json({ error: 'Link not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
links[linkIndex].archived = archived;
|
await link.update({
|
||||||
await writeLinks(links);
|
archived: archived,
|
||||||
|
modified_by: req.user?.username || null
|
||||||
|
});
|
||||||
|
|
||||||
res.json(links[linkIndex]);
|
await link.reload({ include: [{ model: db.List, as: 'lists', attributes: ['id'] }] });
|
||||||
|
|
||||||
|
res.json(formatLink(link));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error updating link:', error);
|
||||||
res.status(500).json({ error: 'Failed to update link' });
|
res.status(500).json({ error: 'Failed to update link' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete a link
|
// Delete a link
|
||||||
app.delete('/api/links/:id', async (req, res) => {
|
app.delete('/api/links/:id', isAuthenticated, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const links = await readLinks();
|
const link = await db.Link.findByPk(id);
|
||||||
const filtered = links.filter(link => link.id !== id);
|
|
||||||
|
|
||||||
if (filtered.length === links.length) {
|
if (!link) {
|
||||||
return res.status(404).json({ error: 'Link not found' });
|
return res.status(404).json({ error: 'Link not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeLinks(filtered);
|
await link.destroy();
|
||||||
res.json({ message: 'Link deleted successfully' });
|
res.json({ message: 'Link deleted successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error deleting link:', error);
|
||||||
res.status(500).json({ error: 'Failed to delete link' });
|
res.status(500).json({ error: 'Failed to delete link' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update link's lists
|
// Update link's lists
|
||||||
app.patch('/api/links/:id/lists', async (req, res) => {
|
app.patch('/api/links/:id/lists', isAuthenticated, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { listIds } = req.body;
|
const { listIds } = req.body;
|
||||||
@@ -717,18 +1064,31 @@ app.patch('/api/links/:id/lists', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'listIds must be an array' });
|
return res.status(400).json({ error: 'listIds must be an array' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const links = await readLinks();
|
const link = await db.Link.findByPk(id);
|
||||||
const linkIndex = links.findIndex(link => link.id === id);
|
|
||||||
|
|
||||||
if (linkIndex === -1) {
|
if (!link) {
|
||||||
return res.status(404).json({ error: 'Link not found' });
|
return res.status(404).json({ error: 'Link not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
links[linkIndex].listIds = listIds;
|
// Find all lists by IDs
|
||||||
await writeLinks(links);
|
const lists = await db.List.findAll({
|
||||||
|
where: { id: { [Op.in]: listIds } }
|
||||||
|
});
|
||||||
|
|
||||||
res.json(links[linkIndex]);
|
// Update relationships
|
||||||
|
await link.setLists(lists);
|
||||||
|
|
||||||
|
// Update modified fields
|
||||||
|
await link.update({
|
||||||
|
modified_by: req.user?.username || null
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload with associations
|
||||||
|
await link.reload({ include: [{ model: db.List, as: 'lists', attributes: ['id'] }] });
|
||||||
|
|
||||||
|
res.json(formatLink(link));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error updating link lists:', error);
|
||||||
res.status(500).json({ error: 'Failed to update link lists' });
|
res.status(500).json({ error: 'Failed to update link lists' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -738,15 +1098,30 @@ app.patch('/api/links/:id/lists', async (req, res) => {
|
|||||||
// Get all lists
|
// Get all lists
|
||||||
app.get('/api/lists', async (req, res) => {
|
app.get('/api/lists', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const lists = await readLists();
|
let lists;
|
||||||
res.json(lists);
|
|
||||||
|
// If user is not authenticated, only return public lists
|
||||||
|
if (!req.isAuthenticated()) {
|
||||||
|
lists = await db.List.findAll({
|
||||||
|
where: { public: true },
|
||||||
|
order: [['created_at', 'DESC']]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Authenticated users see all lists
|
||||||
|
lists = await db.List.findAll({
|
||||||
|
order: [['created_at', 'DESC']]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(lists.map(formatList));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error fetching lists:', error);
|
||||||
res.status(500).json({ error: 'Failed to read lists' });
|
res.status(500).json({ error: 'Failed to read lists' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a new list
|
// Create a new list
|
||||||
app.post('/api/lists', async (req, res) => {
|
app.post('/api/lists', isAuthenticated, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name } = req.body;
|
const { name } = req.body;
|
||||||
|
|
||||||
@@ -754,32 +1129,34 @@ app.post('/api/lists', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'List name is required' });
|
return res.status(400).json({ error: 'List name is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const lists = await readLists();
|
const trimmedName = name.trim();
|
||||||
|
|
||||||
|
// Check if list with same name already exists (case-insensitive)
|
||||||
|
const existingList = await db.List.findOne({
|
||||||
|
where: {
|
||||||
|
name: { [Op.iLike]: trimmedName }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Check if list with same name already exists
|
|
||||||
const existingList = lists.find(list => list.name.toLowerCase() === name.trim().toLowerCase());
|
|
||||||
if (existingList) {
|
if (existingList) {
|
||||||
return res.status(409).json({ error: 'List with this name already exists' });
|
return res.status(409).json({ error: 'List with this name already exists' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const newList = {
|
const newList = await db.List.create({
|
||||||
id: Date.now().toString(),
|
name: trimmedName,
|
||||||
name: name.trim(),
|
created_by: req.user?.username || null,
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
public: false
|
public: false
|
||||||
};
|
});
|
||||||
|
|
||||||
lists.push(newList);
|
res.status(201).json(formatList(newList));
|
||||||
await writeLists(lists);
|
|
||||||
|
|
||||||
res.status(201).json(newList);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error creating list:', error);
|
||||||
res.status(500).json({ error: 'Failed to create list' });
|
res.status(500).json({ error: 'Failed to create list' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update a list
|
// Update a list
|
||||||
app.put('/api/lists/:id', async (req, res) => {
|
app.put('/api/lists/:id', isAuthenticated, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { name } = req.body;
|
const { name } = req.body;
|
||||||
@@ -788,30 +1165,40 @@ app.put('/api/lists/:id', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'List name is required' });
|
return res.status(400).json({ error: 'List name is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const lists = await readLists();
|
const list = await db.List.findByPk(id);
|
||||||
const listIndex = lists.findIndex(list => list.id === id);
|
|
||||||
|
|
||||||
if (listIndex === -1) {
|
if (!list) {
|
||||||
return res.status(404).json({ error: 'List not found' });
|
return res.status(404).json({ error: 'List not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if another list with same name exists
|
const trimmedName = name.trim();
|
||||||
const existingList = lists.find(list => list.id !== id && list.name.toLowerCase() === name.trim().toLowerCase());
|
|
||||||
|
// Check if another list with same name exists (case-insensitive)
|
||||||
|
const existingList = await db.List.findOne({
|
||||||
|
where: {
|
||||||
|
id: { [Op.ne]: id },
|
||||||
|
name: { [Op.iLike]: trimmedName }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (existingList) {
|
if (existingList) {
|
||||||
return res.status(409).json({ error: 'List with this name already exists' });
|
return res.status(409).json({ error: 'List with this name already exists' });
|
||||||
}
|
}
|
||||||
|
|
||||||
lists[listIndex].name = name.trim();
|
await list.update({
|
||||||
await writeLists(lists);
|
name: trimmedName,
|
||||||
|
modified_by: req.user?.username || null
|
||||||
|
});
|
||||||
|
|
||||||
res.json(lists[listIndex]);
|
res.json(formatList(list));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error updating list:', error);
|
||||||
res.status(500).json({ error: 'Failed to update list' });
|
res.status(500).json({ error: 'Failed to update list' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Toggle list public status
|
// Toggle list public status
|
||||||
app.patch('/api/lists/:id/public', async (req, res) => {
|
app.patch('/api/lists/:id/public', isAuthenticated, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { public: isPublic } = req.body;
|
const { public: isPublic } = req.body;
|
||||||
@@ -820,45 +1207,40 @@ app.patch('/api/lists/:id/public', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'public must be a boolean' });
|
return res.status(400).json({ error: 'public must be a boolean' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const lists = await readLists();
|
const list = await db.List.findByPk(id);
|
||||||
const listIndex = lists.findIndex(list => list.id === id);
|
|
||||||
|
|
||||||
if (listIndex === -1) {
|
if (!list) {
|
||||||
return res.status(404).json({ error: 'List not found' });
|
return res.status(404).json({ error: 'List not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
lists[listIndex].public = isPublic;
|
await list.update({
|
||||||
await writeLists(lists);
|
public: isPublic,
|
||||||
|
modified_by: req.user?.username || null
|
||||||
|
});
|
||||||
|
|
||||||
res.json(lists[listIndex]);
|
res.json(formatList(list));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error updating list public status:', error);
|
||||||
res.status(500).json({ error: 'Failed to update list public status' });
|
res.status(500).json({ error: 'Failed to update list public status' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete a list
|
// Delete a list
|
||||||
app.delete('/api/lists/:id', async (req, res) => {
|
app.delete('/api/lists/:id', isAuthenticated, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const lists = await readLists();
|
const list = await db.List.findByPk(id);
|
||||||
const filtered = lists.filter(list => list.id !== id);
|
|
||||||
|
|
||||||
if (filtered.length === lists.length) {
|
if (!list) {
|
||||||
return res.status(404).json({ error: 'List not found' });
|
return res.status(404).json({ error: 'List not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove this list from all links
|
// CASCADE delete will automatically remove from link_lists junction table
|
||||||
const links = await readLinks();
|
await list.destroy();
|
||||||
links.forEach(link => {
|
|
||||||
if (link.listIds && Array.isArray(link.listIds)) {
|
|
||||||
link.listIds = link.listIds.filter(listId => listId !== id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await writeLinks(links);
|
|
||||||
|
|
||||||
await writeLists(filtered);
|
|
||||||
res.json({ message: 'List deleted successfully' });
|
res.json({ message: 'List deleted successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error deleting list:', error);
|
||||||
res.status(500).json({ error: 'Failed to delete list' });
|
res.status(500).json({ error: 'Failed to delete list' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -875,10 +1257,18 @@ function isValidUrl(string) {
|
|||||||
|
|
||||||
// Initialize server
|
// Initialize server
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
await ensureDataDir();
|
try {
|
||||||
app.listen(PORT, () => {
|
// Initialize database (connect, run migrations, migrate JSON files)
|
||||||
console.log(`LinkDing server running on http://localhost:${PORT}`);
|
await initializeDatabase();
|
||||||
});
|
|
||||||
|
// Start server
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`LinkDing server running on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start server:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
startServer();
|
startServer();
|
||||||
|
|||||||
Reference in New Issue
Block a user