Merge pull request 'feat: auth' (#1) from feature/auth into main
Reviewed-on: #1
This commit is contained in:
@@ -2,6 +2,7 @@ node_modules
|
||||
npm-debug.log
|
||||
data
|
||||
.env
|
||||
.env.example
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
|
||||
39
.env.example
Normal file
39
.env.example
Normal file
@@ -0,0 +1,39 @@
|
||||
# LDAP Configuration
|
||||
LDAP_ADDRESS=ldaps://ldap-server:636
|
||||
LDAP_IMPLEMENTATION=custom
|
||||
LDAP_TIMEOUT=5000
|
||||
LDAP_START_TLS=false
|
||||
LDAP_TLS_SERVER_NAME=ldap-server
|
||||
LDAP_TLS_SKIP_VERIFY=true
|
||||
LDAP_TLS_MINIMUM_VERSION=TLS1.2
|
||||
LDAP_BASE_DN=dc=dcentral,dc=systems
|
||||
LDAP_ADDITIONAL_USERS_DN=cn=users
|
||||
LDAP_USERS_FILTER=(&({username_attribute}={input}))
|
||||
LDAP_ADDITIONAL_GROUPS_DN=cn=groups
|
||||
LDAP_GROUPS_FILTER=(cn=users)
|
||||
LDAP_USER=uid=root,cn=users,dc=dcentral,dc=systems
|
||||
LDAP_PASSWORD=super-secret
|
||||
LDAP_ATTRIBUTE_DISTINGUISHED_NAME=distinguishedName
|
||||
LDAP_ATTRIBUTE_USERNAME=uid
|
||||
LDAP_ATTRIBUTE_MAIL=mail
|
||||
LDAP_ATTRIBUTE_MEMBER_OF=memberOf
|
||||
LDAP_ATTRIBUTE_GROUP_NAME=cn
|
||||
|
||||
# Session Configuration
|
||||
SESSION_SECRET=your-secret-key-change-this-in-production
|
||||
|
||||
# Server Configuration
|
||||
PORT=3000
|
||||
|
||||
# Session Configuration
|
||||
SESSION_SECRET=your-secret-key-change-this-in-production
|
||||
SESSION_NAME=connect.sid
|
||||
TRUST_PROXY=true
|
||||
COOKIE_SECURE=true
|
||||
COOKIE_SAMESITE=none
|
||||
COOKIE_DOMAIN=
|
||||
COOKIE_PATH=/
|
||||
|
||||
# Server Configuration
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
289
package-lock.json
generated
289
package-lock.json
generated
@@ -12,7 +12,11 @@
|
||||
"axios": "^1.6.0",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.18.2",
|
||||
"passport": "^0.7.0",
|
||||
"passport-ldapauth": "^3.0.1",
|
||||
"puppeteer-core": "^22.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -25,12 +29,20 @@
|
||||
"integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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/node": {
|
||||
"version": "24.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz",
|
||||
"integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@@ -45,6 +57,12 @@
|
||||
"@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": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
@@ -111,6 +129,24 @@
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"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": {
|
||||
"version": "0.13.4",
|
||||
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
|
||||
@@ -154,6 +190,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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -281,6 +329,12 @@
|
||||
"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": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -599,6 +653,12 @@
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"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": {
|
||||
"version": "2.8.5",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
|
||||
@@ -761,6 +821,18 @@
|
||||
"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/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -1006,6 +1078,40 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
|
||||
@@ -1049,6 +1155,15 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"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": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
|
||||
@@ -1582,6 +1697,53 @@
|
||||
"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/lru-cache": {
|
||||
"version": "7.18.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
|
||||
@@ -1812,6 +1974,15 @@
|
||||
"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": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
@@ -1934,12 +2105,56 @@
|
||||
"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": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
@@ -1959,6 +2174,14 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
@@ -2113,6 +2336,15 @@
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
@@ -2561,6 +2793,18 @@
|
||||
"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": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz",
|
||||
@@ -2591,8 +2835,7 @@
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
@@ -2627,6 +2870,46 @@
|
||||
"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": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||
|
||||
@@ -18,7 +18,11 @@
|
||||
"axios": "^1.6.0",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.18.2",
|
||||
"passport": "^0.7.0",
|
||||
"passport-ldapauth": "^3.0.1",
|
||||
"puppeteer-core": "^22.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
424
public/app.js
424
public/app.js
@@ -1,5 +1,6 @@
|
||||
const API_BASE = '/api/links';
|
||||
const LISTS_API_BASE = '/api/lists';
|
||||
const AUTH_API_BASE = '/api/auth';
|
||||
|
||||
// DOM elements
|
||||
const linkForm = document.getElementById('linkForm');
|
||||
@@ -29,6 +30,17 @@ const listFilterWrapper = document.getElementById('listFilterWrapper');
|
||||
const listFilterChips = document.getElementById('listFilterChips');
|
||||
const clearListFilters = document.getElementById('clearListFilters');
|
||||
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
|
||||
let allLinks = [];
|
||||
@@ -38,15 +50,20 @@ let showArchived = false;
|
||||
let currentLayout = localStorage.getItem('linkdingLayout') || 'masonry'; // Load from localStorage or default
|
||||
let selectedListFilters = [];
|
||||
let currentLinkForListSelection = null;
|
||||
let isAuthenticated = false;
|
||||
let currentUser = null;
|
||||
|
||||
// Initialize app
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
checkAuthStatus().then(() => {
|
||||
loadLinks();
|
||||
loadLists().then(() => {
|
||||
// After lists are loaded, check URL for list filter
|
||||
checkUrlForListFilter();
|
||||
});
|
||||
});
|
||||
setupEventListeners();
|
||||
setupAuthentication();
|
||||
setupScrollToTop();
|
||||
setupMobileSearch();
|
||||
setupMobileAddLink();
|
||||
@@ -54,10 +71,35 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
setupListsManagement();
|
||||
setupListFiltering();
|
||||
setupUrlNavigation();
|
||||
setupLogoClick();
|
||||
setupMobileButtonBlur();
|
||||
applyLayout(currentLayout);
|
||||
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
|
||||
function registerServiceWorker() {
|
||||
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
|
||||
function setupEventListeners() {
|
||||
linkForm.addEventListener('submit', handleAddLink);
|
||||
searchInput.addEventListener('input', handleSearch);
|
||||
archiveToggle.addEventListener('click', handleToggleArchive);
|
||||
archiveToggle.addEventListener('change', handleToggleArchive);
|
||||
}
|
||||
|
||||
// Setup search toggle (works on both desktop and mobile)
|
||||
@@ -91,10 +334,20 @@ function setupMobileSearch() {
|
||||
// Hide search
|
||||
searchWrapper.classList.remove('show');
|
||||
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
|
||||
if (addLinkWrapper && addLinkWrapper.classList.contains('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
|
||||
if (searchInput.value.trim()) {
|
||||
@@ -105,7 +358,10 @@ function setupMobileSearch() {
|
||||
// Hide add link if visible
|
||||
if (addLinkWrapper && addLinkWrapper.classList.contains('show')) {
|
||||
addLinkWrapper.classList.remove('show');
|
||||
if (addLinkToggle) addLinkToggle.classList.remove('active');
|
||||
if (addLinkToggle) {
|
||||
addLinkToggle.classList.remove('active');
|
||||
addLinkToggle.blur();
|
||||
}
|
||||
}
|
||||
// Show search
|
||||
searchWrapper.classList.add('show');
|
||||
@@ -129,6 +385,13 @@ function setupMobileAddLink() {
|
||||
// Hide add link
|
||||
addLinkWrapper.classList.remove('show');
|
||||
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
|
||||
if (linkInput.value.trim()) {
|
||||
linkInput.value = '';
|
||||
@@ -137,7 +400,10 @@ function setupMobileAddLink() {
|
||||
// Hide search if visible
|
||||
if (searchWrapper && searchWrapper.classList.contains('show')) {
|
||||
searchWrapper.classList.remove('show');
|
||||
if (searchToggle) searchToggle.classList.remove('active');
|
||||
if (searchToggle) {
|
||||
searchToggle.classList.remove('active');
|
||||
searchToggle.blur();
|
||||
}
|
||||
if (searchInput.value.trim()) {
|
||||
searchInput.value = '';
|
||||
loadLinks();
|
||||
@@ -161,8 +427,18 @@ function setupLayoutToggle() {
|
||||
// Toggle dropdown on button click
|
||||
layoutToggle.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const wasVisible = layoutDropdown.classList.contains('show');
|
||||
layoutDropdown.classList.toggle('show');
|
||||
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
|
||||
@@ -170,6 +446,13 @@ function setupLayoutToggle() {
|
||||
if (!layoutToggle.contains(e.target) && !layoutDropdown.contains(e.target)) {
|
||||
layoutDropdown.classList.remove('show');
|
||||
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: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ url })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
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
|
||||
data.archived = data.archived || false;
|
||||
data.listIds = data.listIds || [];
|
||||
@@ -297,6 +582,7 @@ async function handleAddLink(e) {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ listIds: selectedListFilters })
|
||||
});
|
||||
|
||||
@@ -369,8 +655,7 @@ function handleSearch(e) {
|
||||
|
||||
// Handle archive toggle
|
||||
function handleToggleArchive() {
|
||||
showArchived = !showArchived;
|
||||
archiveToggle.classList.toggle('active', showArchived);
|
||||
showArchived = archiveToggle.checked;
|
||||
|
||||
// Re-filter and display links
|
||||
const query = searchInput.value.trim();
|
||||
@@ -424,6 +709,9 @@ function displayLinks(links) {
|
||||
handleAddToLists(linkId);
|
||||
});
|
||||
});
|
||||
|
||||
// Update UI based on auth status after rendering
|
||||
updateUIBasedOnAuth();
|
||||
}
|
||||
|
||||
// Create link card HTML
|
||||
@@ -566,10 +854,14 @@ async function handleArchiveLink(id) {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
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
|
||||
link.archived = newArchivedStatus;
|
||||
@@ -598,10 +890,14 @@ async function handleDeleteLink(id) {
|
||||
|
||||
try {
|
||||
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
|
||||
allLinks = allLinks.filter(link => link.id !== id);
|
||||
@@ -665,11 +961,27 @@ async function loadLists() {
|
||||
// Setup lists management
|
||||
function setupListsManagement() {
|
||||
// Toggle filter section visibility
|
||||
listsToggle.addEventListener('click', () => {
|
||||
listsToggle.addEventListener('click', (e) => {
|
||||
const isVisible = listFilterWrapper.classList.contains('show');
|
||||
if (isVisible) {
|
||||
listFilterWrapper.classList.remove('show');
|
||||
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
|
||||
selectedListFilters = [];
|
||||
updateUrlForListFilter();
|
||||
@@ -817,15 +1129,17 @@ async function handleCreateList() {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
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);
|
||||
newListName.value = '';
|
||||
renderListsList();
|
||||
@@ -856,15 +1170,17 @@ async function handleUpdateList(listId) {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
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);
|
||||
if (listIndex !== -1) {
|
||||
allLists[listIndex] = data;
|
||||
@@ -899,15 +1215,17 @@ async function handleTogglePublic(listId) {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ public: newPublicStatus })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
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
|
||||
const listIndex = allLists.findIndex(l => l.id === listId);
|
||||
if (listIndex !== -1) {
|
||||
@@ -930,10 +1248,14 @@ async function handleDeleteList(listId) {
|
||||
|
||||
try {
|
||||
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);
|
||||
selectedListFilters = selectedListFilters.filter(id => id !== listId);
|
||||
@@ -992,15 +1314,17 @@ async function handleCheckboxChange(linkId) {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ listIds: selectedListIds })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
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
|
||||
const linkIndex = allLinks.findIndex(l => l.id === linkId);
|
||||
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
|
||||
function setupListFiltering() {
|
||||
clearListFilters.addEventListener('click', () => {
|
||||
@@ -1081,7 +1440,14 @@ function updateUrlForListFilter() {
|
||||
|
||||
// Update list filter chips
|
||||
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
|
||||
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;
|
||||
@@ -1092,7 +1458,7 @@ function updateListFilterChips() {
|
||||
return;
|
||||
}
|
||||
|
||||
listFilterChips.innerHTML = allLists.map(list => {
|
||||
listFilterChips.innerHTML = listsToShow.map(list => {
|
||||
const isSelected = selectedListFilters.includes(list.id);
|
||||
return `
|
||||
<button class="list-filter-chip ${isSelected ? 'active' : ''}" data-id="${list.id}">
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
<div class="header-container">
|
||||
<div class="header-row">
|
||||
<div class="header-left">
|
||||
<h1 class="app-title">
|
||||
<span class="title-text">🔗 Links</span>
|
||||
<h1 class="app-title" id="appLogo">
|
||||
<span class="title-text"><img src="/icon-192.png" alt="LinkDing" class="title-icon"></span>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
@@ -39,13 +39,6 @@
|
||||
<line x1="2" y1="18" x2="2.01" y2="18"></line>
|
||||
</svg>
|
||||
</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">
|
||||
<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">
|
||||
@@ -82,6 +75,22 @@
|
||||
<path d="m21 21-4.35-4.35"></path>
|
||||
</svg>
|
||||
</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 class="header-fields-container">
|
||||
@@ -126,6 +135,11 @@
|
||||
<div class="list-filter-header">
|
||||
<span class="list-filter-label"><b>Lists</b></span>
|
||||
<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">
|
||||
<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>
|
||||
@@ -144,6 +158,54 @@
|
||||
</div>
|
||||
</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 -->
|
||||
<div id="listsModal" class="lists-modal">
|
||||
<div class="lists-modal-content">
|
||||
|
||||
@@ -87,11 +87,20 @@ body {
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
letter-spacing: -0.02em;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.app-title:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
font-size: 1.5rem;
|
||||
filter: drop-shadow(0 2px 4px rgba(99, 102, 241, 0.3));
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
@@ -110,40 +119,6 @@ body {
|
||||
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-wrapper {
|
||||
position: relative;
|
||||
@@ -439,6 +414,74 @@ body {
|
||||
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-btn {
|
||||
display: flex;
|
||||
@@ -1044,6 +1087,60 @@ body {
|
||||
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 {
|
||||
display: flex;
|
||||
}
|
||||
@@ -1168,11 +1265,6 @@ body {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.archive-toggle-wrapper {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.scroll-to-top-btn {
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
@@ -1252,6 +1344,70 @@ body {
|
||||
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 {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
@@ -1582,6 +1738,149 @@ body {
|
||||
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 {
|
||||
position: fixed;
|
||||
@@ -1626,7 +1925,7 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.5rem;
|
||||
padding: 0.8rem;
|
||||
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 = [
|
||||
'/',
|
||||
'/index.html',
|
||||
|
||||
217
server.js
217
server.js
@@ -1,5 +1,9 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
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 path = require('path');
|
||||
const axios = require('axios');
|
||||
@@ -66,11 +70,126 @@ const PORT = process.env.PORT || 3000;
|
||||
const DATA_FILE = path.join(__dirname, 'data', 'links.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
|
||||
app.use(cors());
|
||||
app.use(cors({
|
||||
origin: true,
|
||||
credentials: true
|
||||
}));
|
||||
app.use(express.json());
|
||||
app.use(express.static('public'));
|
||||
|
||||
// Authentication middleware
|
||||
function isAuthenticated(req, res, next) {
|
||||
if (req.isAuthenticated()) {
|
||||
return next();
|
||||
}
|
||||
res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
// Ensure data directory exists
|
||||
async function ensureDataDir() {
|
||||
const dataDir = path.dirname(DATA_FILE);
|
||||
@@ -589,12 +708,70 @@ 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
|
||||
|
||||
// Get all links
|
||||
app.get('/api/links', async (req, res) => {
|
||||
try {
|
||||
const links = await readLinks();
|
||||
|
||||
// If user is not authenticated, only show links in public lists
|
||||
if (!req.isAuthenticated()) {
|
||||
const lists = await readLists();
|
||||
const publicListIds = lists.filter(list => list.public === true).map(list => list.id);
|
||||
|
||||
// Filter links to only those that are in at least one public list
|
||||
const filteredLinks = links.filter(link => {
|
||||
const linkListIds = link.listIds || [];
|
||||
return linkListIds.some(listId => publicListIds.includes(listId));
|
||||
});
|
||||
|
||||
return res.json(filteredLinks);
|
||||
}
|
||||
|
||||
// Authenticated users see all links
|
||||
res.json(links);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to read links' });
|
||||
@@ -605,7 +782,19 @@ app.get('/api/links', async (req, res) => {
|
||||
app.get('/api/links/search', async (req, res) => {
|
||||
try {
|
||||
const query = req.query.q?.toLowerCase() || '';
|
||||
const links = await readLinks();
|
||||
let links = await readLinks();
|
||||
|
||||
// If user is not authenticated, only show links in public lists
|
||||
if (!req.isAuthenticated()) {
|
||||
const lists = await readLists();
|
||||
const publicListIds = lists.filter(list => list.public === true).map(list => list.id);
|
||||
|
||||
// Filter links to only those that are in at least one public list
|
||||
links = links.filter(link => {
|
||||
const linkListIds = link.listIds || [];
|
||||
return linkListIds.some(listId => publicListIds.includes(listId));
|
||||
});
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
return res.json(links);
|
||||
@@ -625,7 +814,7 @@ app.get('/api/links/search', async (req, res) => {
|
||||
});
|
||||
|
||||
// Add a new link
|
||||
app.post('/api/links', async (req, res) => {
|
||||
app.post('/api/links', isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const { url } = req.body;
|
||||
|
||||
@@ -664,7 +853,7 @@ app.post('/api/links', async (req, res) => {
|
||||
});
|
||||
|
||||
// Archive/Unarchive a link
|
||||
app.patch('/api/links/:id/archive', async (req, res) => {
|
||||
app.patch('/api/links/:id/archive', isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { archived } = req.body;
|
||||
@@ -690,7 +879,7 @@ app.patch('/api/links/:id/archive', async (req, res) => {
|
||||
});
|
||||
|
||||
// Delete a link
|
||||
app.delete('/api/links/:id', async (req, res) => {
|
||||
app.delete('/api/links/:id', isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const links = await readLinks();
|
||||
@@ -708,7 +897,7 @@ app.delete('/api/links/:id', async (req, res) => {
|
||||
});
|
||||
|
||||
// Update link's lists
|
||||
app.patch('/api/links/:id/lists', async (req, res) => {
|
||||
app.patch('/api/links/:id/lists', isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { listIds } = req.body;
|
||||
@@ -739,6 +928,14 @@ app.patch('/api/links/:id/lists', async (req, res) => {
|
||||
app.get('/api/lists', async (req, res) => {
|
||||
try {
|
||||
const lists = await readLists();
|
||||
|
||||
// If user is not authenticated, only return public lists
|
||||
if (!req.isAuthenticated()) {
|
||||
const publicLists = lists.filter(list => list.public === true);
|
||||
return res.json(publicLists);
|
||||
}
|
||||
|
||||
// Authenticated users see all lists
|
||||
res.json(lists);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to read lists' });
|
||||
@@ -746,7 +943,7 @@ app.get('/api/lists', async (req, res) => {
|
||||
});
|
||||
|
||||
// Create a new list
|
||||
app.post('/api/lists', async (req, res) => {
|
||||
app.post('/api/lists', isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const { name } = req.body;
|
||||
|
||||
@@ -779,7 +976,7 @@ app.post('/api/lists', async (req, res) => {
|
||||
});
|
||||
|
||||
// Update a list
|
||||
app.put('/api/lists/:id', async (req, res) => {
|
||||
app.put('/api/lists/:id', isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name } = req.body;
|
||||
@@ -811,7 +1008,7 @@ app.put('/api/lists/:id', async (req, res) => {
|
||||
});
|
||||
|
||||
// Toggle list public status
|
||||
app.patch('/api/lists/:id/public', async (req, res) => {
|
||||
app.patch('/api/lists/:id/public', isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { public: isPublic } = req.body;
|
||||
@@ -837,7 +1034,7 @@ app.patch('/api/lists/:id/public', async (req, res) => {
|
||||
});
|
||||
|
||||
// Delete a list
|
||||
app.delete('/api/lists/:id', async (req, res) => {
|
||||
app.delete('/api/lists/:id', isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const lists = await readLists();
|
||||
|
||||
Reference in New Issue
Block a user