Compare commits

4 Commits

Author SHA1 Message Date
3cf9601a71 Merge pull request 'feat: auth' (#1) from feature/auth into main
Reviewed-on: #1
2025-11-16 09:40:33 +01:00
2ea90554ef feat: auth 2025-11-16 07:49:19 +01:00
3c372878a3 feat: UX improvements for list handling 2025-11-15 19:21:40 +01:00
3378a13fe4 feat: lists 2025-11-15 19:11:07 +01:00
9 changed files with 2820 additions and 95 deletions

View File

@@ -2,6 +2,7 @@ node_modules
npm-debug.log
data
.env
.env.example
.git
.gitignore
*.md

39
.env.example Normal file
View 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
View File

@@ -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",

View File

@@ -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": {

File diff suppressed because it is too large Load Diff

View File

@@ -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">
@@ -29,11 +29,14 @@
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
</button>
<button id="archiveToggle" class="archive-toggle-btn" title="Toggle archived links">
<button id="listsToggle" class="lists-toggle-btn" title="Manage lists">
<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"/>
<line x1="5" y1="6" x2="19" y2="6"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
<line x1="5" y1="18" x2="19" y2="18"></line>
<line x1="2" y1="6" x2="2.01" y2="6"></line>
<line x1="2" y1="12" x2="2.01" y2="12"></line>
<line x1="2" y1="18" x2="2.01" y2="18"></line>
</svg>
</button>
<div class="layout-toggle-wrapper">
@@ -72,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">
@@ -112,10 +131,128 @@
autocomplete="off"
>
</div>
<div class="list-filter-wrapper" id="listFilterWrapper">
<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>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
<span>Edit</span>
</button>
<button id="clearListFilters" class="clear-list-filters-btn" title="Clear filters">Clear</button>
</div>
</div>
<div class="list-filter-chips" id="listFilterChips">
<!-- List filter chips will be inserted here -->
</div>
</div>
</div>
</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">
<div class="lists-modal-header">
<h2>Manage Lists</h2>
<button id="closeListsModal" 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="lists-modal-body">
<div class="create-list-form">
<input
type="text"
id="newListName"
placeholder="Enter list name..."
autocomplete="off"
>
<button id="createListBtn" class="btn-create-list">Create List</button>
</div>
<div class="lists-list" id="listsList">
<!-- Lists will be inserted here -->
</div>
</div>
</div>
</div>
<!-- List Selection Overlay -->
<div id="listSelectionOverlay" class="list-selection-overlay">
<div class="list-selection-content">
<div class="list-selection-header">
<h3>Add to Lists</h3>
<button id="closeListSelection" class="close-overlay-btn">
<svg width="20" height="20" 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="list-selection-body" id="listSelectionBody">
<!-- List checkboxes will be inserted here -->
</div>
</div>
</div>
<div class="container">
<div id="linksContainer" class="links-container">
<div class="empty-state">

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = 'linkding-v1';
const CACHE_NAME = 'linkding-v2.0.1';
const urlsToCache = [
'/',
'/index.html',
@@ -36,12 +36,14 @@ self.addEventListener('activate', (event) => {
}
})
);
}).then(() => {
// Claim all clients immediately to ensure new service worker takes control
return self.clients.claim();
})
);
return self.clients.claim();
});
// Fetch event - serve from cache, fallback to network
// Fetch event - network first for HTML/CSS/JS, cache first for images
self.addEventListener('fetch', (event) => {
// API requests - always fetch from network, don't cache
if (event.request.url.includes('/api/')) {
@@ -49,12 +51,17 @@ self.addEventListener('fetch', (event) => {
return;
}
// Static assets - cache first, then network
event.respondWith(
caches.match(event.request)
.then((response) => {
// Return cached version or fetch from network
return response || fetch(event.request).then((response) => {
const url = new URL(event.request.url);
const isHTML = event.request.destination === 'document' || url.pathname === '/' || url.pathname.endsWith('.html');
const isCSS = url.pathname.endsWith('.css');
const isJS = url.pathname.endsWith('.js');
const isImage = event.request.destination === 'image' || url.pathname.match(/\.(png|jpg|jpeg|gif|svg|webp|ico)$/i);
// For HTML, CSS, and JS: Network first, then cache (for offline support)
if (isHTML || isCSS || isJS) {
event.respondWith(
fetch(event.request)
.then((response) => {
// Don't cache non-successful responses
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
@@ -69,14 +76,55 @@ self.addEventListener('fetch', (event) => {
});
return response;
});
})
.catch(() => {
// If both cache and network fail, return offline page if available
if (event.request.destination === 'document') {
return caches.match('/index.html');
}
})
);
})
.catch(() => {
// Network failed, try cache
return caches.match(event.request).then((response) => {
// If it's a document request and cache fails, return index.html
if (!response && isHTML) {
return caches.match('/index.html');
}
return response;
});
})
);
return;
}
// For images and other static assets: Cache first, then network
if (isImage) {
event.respondWith(
caches.match(event.request)
.then((response) => {
// Return cached version or fetch from network
return response || fetch(event.request).then((response) => {
// Don't cache non-successful responses
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// Clone the response
const responseToCache = response.clone();
caches.open(CACHE_NAME)
.then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
});
})
.catch(() => {
// If both cache and network fail, return offline page if available
if (event.request.destination === 'document') {
return caches.match('/index.html');
}
})
);
return;
}
// For other requests: Network first
event.respondWith(fetch(event.request));
});

384
server.js
View File

@@ -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');
@@ -64,12 +68,128 @@ function findChromeExecutable() {
const app = express();
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);
@@ -83,6 +203,11 @@ async function ensureDataDir() {
} catch {
await fs.writeFile(DATA_FILE, JSON.stringify([]));
}
try {
await fs.access(LISTS_FILE);
} catch {
await fs.writeFile(LISTS_FILE, JSON.stringify([]));
}
}
// Read links from file
@@ -100,6 +225,21 @@ async function writeLinks(links) {
await fs.writeFile(DATA_FILE, JSON.stringify(links, null, 2));
}
// Read lists from file
async function readLists() {
try {
const data = await fs.readFile(LISTS_FILE, 'utf8');
return JSON.parse(data);
} catch (error) {
return [];
}
}
// Write lists to file
async function writeLists(lists) {
await fs.writeFile(LISTS_FILE, JSON.stringify(lists, null, 2));
}
// Extract metadata using Puppeteer (for JavaScript-heavy sites)
async function extractMetadataWithPuppeteer(url) {
const pptr = await getPuppeteer();
@@ -568,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' });
@@ -584,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);
@@ -604,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;
@@ -643,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;
@@ -669,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();
@@ -686,6 +896,170 @@ app.delete('/api/links/:id', async (req, res) => {
}
});
// Update link's lists
app.patch('/api/links/:id/lists', isAuthenticated, async (req, res) => {
try {
const { id } = req.params;
const { listIds } = req.body;
if (!Array.isArray(listIds)) {
return res.status(400).json({ error: 'listIds must be an array' });
}
const links = await readLinks();
const linkIndex = links.findIndex(link => link.id === id);
if (linkIndex === -1) {
return res.status(404).json({ error: 'Link not found' });
}
links[linkIndex].listIds = listIds;
await writeLinks(links);
res.json(links[linkIndex]);
} catch (error) {
res.status(500).json({ error: 'Failed to update link lists' });
}
});
// Lists API Routes
// Get all lists
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' });
}
});
// Create a new list
app.post('/api/lists', isAuthenticated, async (req, res) => {
try {
const { name } = req.body;
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return res.status(400).json({ error: 'List name is required' });
}
const lists = await readLists();
// Check if list with same name already exists
const existingList = lists.find(list => list.name.toLowerCase() === name.trim().toLowerCase());
if (existingList) {
return res.status(409).json({ error: 'List with this name already exists' });
}
const newList = {
id: Date.now().toString(),
name: name.trim(),
createdAt: new Date().toISOString(),
public: false
};
lists.push(newList);
await writeLists(lists);
res.status(201).json(newList);
} catch (error) {
res.status(500).json({ error: 'Failed to create list' });
}
});
// Update a list
app.put('/api/lists/:id', isAuthenticated, async (req, res) => {
try {
const { id } = req.params;
const { name } = req.body;
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return res.status(400).json({ error: 'List name is required' });
}
const lists = await readLists();
const listIndex = lists.findIndex(list => list.id === id);
if (listIndex === -1) {
return res.status(404).json({ error: 'List not found' });
}
// Check if another list with same name exists
const existingList = lists.find(list => list.id !== id && list.name.toLowerCase() === name.trim().toLowerCase());
if (existingList) {
return res.status(409).json({ error: 'List with this name already exists' });
}
lists[listIndex].name = name.trim();
await writeLists(lists);
res.json(lists[listIndex]);
} catch (error) {
res.status(500).json({ error: 'Failed to update list' });
}
});
// Toggle list public status
app.patch('/api/lists/:id/public', isAuthenticated, async (req, res) => {
try {
const { id } = req.params;
const { public: isPublic } = req.body;
if (typeof isPublic !== 'boolean') {
return res.status(400).json({ error: 'public must be a boolean' });
}
const lists = await readLists();
const listIndex = lists.findIndex(list => list.id === id);
if (listIndex === -1) {
return res.status(404).json({ error: 'List not found' });
}
lists[listIndex].public = isPublic;
await writeLists(lists);
res.json(lists[listIndex]);
} catch (error) {
res.status(500).json({ error: 'Failed to update list public status' });
}
});
// Delete a list
app.delete('/api/lists/:id', isAuthenticated, async (req, res) => {
try {
const { id } = req.params;
const lists = await readLists();
const filtered = lists.filter(list => list.id !== id);
if (filtered.length === lists.length) {
return res.status(404).json({ error: 'List not found' });
}
// Remove this list from all links
const links = await readLinks();
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' });
} catch (error) {
res.status(500).json({ error: 'Failed to delete list' });
}
});
// Helper function to validate URL
function isValidUrl(string) {
try {