Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
# espial image name:tag.
# APPIMAGE=espial:espial

# Optional: pin the session cookie key across container recreation
# CLIENT_SESSION_KEY=

# Optional host path mounted to /app/data in the espial container.
# DATA=.

Expand Down
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -91,5 +91,7 @@ COPY --from=builder /src/static ./static
COPY --from=builder /opt/espial/bin/espial ./espial
COPY --from=builder /opt/espial/bin/migration ./migration

ENV SQLITE_DATABASE=/app/data/espial.sqlite3

ENTRYPOINT []
CMD ["./espial", "+RTS", "-T"]
2 changes: 2 additions & 0 deletions Dockerfile.buildkit
Original file line number Diff line number Diff line change
Expand Up @@ -100,5 +100,7 @@ COPY --from=builder /src/static ./static
COPY --from=builder /opt/espial/bin/espial ./espial
COPY --from=builder /opt/espial/bin/migration ./migration

ENV SQLITE_DATABASE=/app/data/espial.sqlite3

ENTRYPOINT []
CMD ["./espial", "+RTS", "-T"]
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,11 @@ For a quick trial, or a minimal setup without cloning [espial-docker](https://gi
MSYS_NO_PATHCONV=1 docker run --name espial \
-p 9090:3000 \
-v espial-data:/app/data \
-e SQLITE_DATABASE=/app/data/espial.sqlite3 \
-d jonschoning/espial:espial
```

- Maps host port `9090` to Espial's internal port `3000` — change `9090` to whatever port you prefer.
- Creates a named volume called `espial-data` at `/app/data`; the sqlite database will be stored inside a docker Named Volume.
- `SQLITE_DATABASE` sets the database filename inside the named volume at `/app/data`
- The database is created and migrated automatically on startup — no separate `createdb` step required.

2. Create a user:

Expand Down Expand Up @@ -501,6 +498,11 @@ All commands take an optional `--conn` parameter for the database location; if o
| `printmigratedb` | `stack exec migration -- printmigratedb` |
| `runmigratedb` | `stack exec migration -- runmigratedb` |
| `showuser` | `stack exec migration -- showuser --userName myusername` |
| `generatesessionkey` | `stack exec migration -- generatesessionkey` |

### `generatesessionkey` Command Notes:

Prints a base64-encoded client session key suitable for the `CLIENT_SESSION_KEY` environment variable. When set, it is used instead of `config/client_session_key.aes`, so sessions survive container recreation (avoiding forced re-login across updated docker images).

### `importbookmarks` Command Notes:

Expand Down
3 changes: 3 additions & 0 deletions app/migration/Main.hs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import Options.Applicative qualified as OA
import Options.Generic
import Settings (AppSettings (..), appPasswordHashConfig)
import Types
import Web.ClientSession qualified as CS
import Yesod.Default.Config2 (configSettingsYml, loadYamlSettings, useEnv)

data MigrationOpts
Expand Down Expand Up @@ -97,6 +98,7 @@ data MigrationOpts
{ conn :: Maybe Text,
silent :: Maybe Bool
}
| GenerateSessionKey
deriving (Generic, Show)

instance ParseRecord MigrationOpts
Expand Down Expand Up @@ -296,6 +298,7 @@ main = do
case muser of
Just (P.Entity uid _) -> exportNetscapeBookmarks uid bookmarkFile
Nothing -> liftIO (print (userName ++ "not found"))
GenerateSessionKey -> void $ CS.randomKeyEnv "CLIENT_SESSION_KEY"
where
getConnText :: Maybe Text -> IO Text
getConnText mconn =
Expand Down
15 changes: 15 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## v0.0.42 (2026-07-25)

- preserve filter/search context when clicking on page & tag filters
- preserve page filters and search when filtering on a tag (`t:book`)
- preserve tags and search when selecting a page filter (`starred`)
- example generated route: `u:demo/starred/t:book?sort=title&query=title%3Aalgebra`
- add boolean search operators
- bookmarks: `private:`/`pr:`, `starred:`/`st:`, `unread:`/`un:`
- notes: `private:`/`pr:`
- extend `migration` command with `generatesessionkey`, for use with pinning
the client session cookie key via env var `CLIENT_SESSION_KEY` (useful to avoid forced re-login across updated docker
images, see `espial-docker`'s readme for more info)
- improve fetchPageTitle implementation for reddit, youtube, tiktok, spotify
- bake ENV SQLITE_DATABASE into dockerfile

## v0.0.41 (2026-07-18)

- add sorting ability on bookmarks (time,title,url,tagcount) and notes (created, title) (#88)
Expand Down
4 changes: 4 additions & 0 deletions config/routes
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,15 @@
!/#UserNameP UserR GET
!/#UserNameP/#SharedP UserSharedR GET
!/#UserNameP/#FilterP UserFilterR GET
!/#UserNameP/#SharedP/#TagsP UserSharedTagsR GET
!/#UserNameP/#FilterP/#TagsP UserFilterTagsR GET
!/#UserNameP/#TagsP UserTagsR GET

!/#UserNameP/feed.xml UserFeedR GET
!/#UserNameP/#SharedP/feed.xml UserFeedSharedR GET
!/#UserNameP/#FilterP/feed.xml UserFeedFilterR GET
!/#UserNameP/#SharedP/#TagsP/feed.xml UserFeedSharedTagsR GET
!/#UserNameP/#FilterP/#TagsP/feed.xml UserFeedFilterTagsR GET
!/#UserNameP/#TagsP/feed.xml UserFeedTagsR GET

-- settings
Expand Down
1 change: 1 addition & 0 deletions docker-compose.archivebox07.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ services:
PORT: 3000
SQLITE_DATABASE: /app/data/espial.sqlite3
IP_FROM_HEADER: false
CLIENT_SESSION_KEY: ${CLIENT_SESSION_KEY}
# SSL_ONLY: false
# Optional: enable in-process TLS (reverse proxy is recommended; see README § TLS / Reverse Proxy)
# TLS_CERT_FILE: /app/data/tls/cert.pem
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ services:
PORT: 3000
SQLITE_DATABASE: /app/data/espial.sqlite3
IP_FROM_HEADER: false
CLIENT_SESSION_KEY: ${CLIENT_SESSION_KEY}
# SSL_ONLY: false
# Optional: enable in-process TLS (reverse proxy is recommended; see README § TLS / Reverse Proxy)
# TLS_CERT_FILE: /app/data/tls/cert.pem
Expand Down
12 changes: 7 additions & 5 deletions espial.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ cabal-version: 2.2
-- see: https://github.com/sol/hpack

name: espial
version: 0.0.41
version: 0.0.42
synopsis: Espial is an open-source, web-based bookmarking server.
description: Espial is an open-source, web-based bookmarking server.
- Yesod + TypeScript + sqlite3
Expand Down Expand Up @@ -70,6 +70,7 @@ extra-source-files:
frontend/src/stores/bookmarksStore.ts
frontend/src/stores/tagCloudStore.ts
frontend/src/types.ts
frontend/src/urlBuild.ts
frontend/src/util.ts
frontend/tsconfig.json
hie.yaml
Expand All @@ -86,10 +87,10 @@ extra-source-files:
static/fonts/glyphicons-halflings-regular.ttf
static/fonts/glyphicons-halflings-regular.woff
static/images/bluepin.gif
static/js/app-LILLUWFD.min.js
static/js/app-LILLUWFD.min.js.gz
static/js/app-LILLUWFD.min.js.map
static/js/app-LILLUWFD.min.js.map.gz
static/js/app-OXLWASYB.min.js
static/js/app-OXLWASYB.min.js.gz
static/js/app-OXLWASYB.min.js.map
static/js/app-OXLWASYB.min.js.map.gz
static/js/js.cookie-2.2.0.min.js
static/js/js.cookie-2.2.0.min.js.gz
static/js/manifest.json
Expand Down Expand Up @@ -422,6 +423,7 @@ executable migration
, classy-prelude >=1.4 && <1.6
, classy-prelude-conduit >=1.4 && <1.6
, classy-prelude-yesod >=1.4 && <1.6
, clientsession
, conduit >=1.0 && <2
, containers
, cryptohash-sha256
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/components/BMark.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { archiveBookmark, destroy, editBookmark, lookupTitle, markRead, toggleSt
import { app, setFocus, shdatetime, toLocaleDateString } from '../globals';
import { useTagSuggestions } from '../hooks/useTagSuggestions';
import type { Bookmark } from '../types';
import { apiErrorMsg, encodeTag, fromNullableStr, normalizeTags } from '../util';
import { buildTagUrl } from '../urlBuild';
import { apiErrorMsg, fromNullableStr, normalizeTags } from '../util';
import { Markdown } from './Markdown';
import { TagSuggestionsDropdown } from './TagSuggestionsDropdown';

Expand Down Expand Up @@ -70,7 +71,7 @@ export function BMark({
const tagInputId = `${bm.bid.toString()}_tags`;

const linkToFilterSingle = `${fromNullableStr(a.userR)}/b:${bm.slug}`;
const linkToFilterTag = (tag: string) => `${fromNullableStr(a.userR)}/t:${encodeTag(tag)}`;
const linkToFilterTag = (tag: string) => buildTagUrl(a, [tag]);
const viewInContextTime = (time: Date) => {
const t = new Date(time);
const t2 = new Date(t.getTime() + 2);
Expand Down
19 changes: 5 additions & 14 deletions frontend/src/components/TagCloud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
tagCloudModeFromF,
type TagCloudModeRelated,
} from '../types';
import { encodeTag, fromNullableStr } from '../util';
import { buildTagUrl } from '../urlBuild';

/** Binds click handlers on the server-rendered tag cloud header (mode/filter controls) to the tag cloud store. */
export function bindTagCloudHeader(renderElSelector: string) {
Expand Down Expand Up @@ -171,8 +171,7 @@ function renderLinks(curtags: string[], tagcloud: TagCloud) {
const a = app();
const cur = curtags.map((tag) => tag.toLowerCase());

const linkToFilterTag = (rest: string) =>
`${fromNullableStr(a.userR)}${rest === '' ? '' : `/t:${rest}`}`;
const linkToFilterTag = (tags: string[]) => buildTagUrl(a, tags);

const tagCounts = Object.values(tagcloud);
const cMin = tagCounts.length ? Math.min(...tagCounts) : 1;
Expand All @@ -188,20 +187,12 @@ function renderLinks(curtags: string[], tagcloud: TagCloud) {

const includeExcludeLink =
cur.length === 0 ? null : !cur.includes(kLower) ? (
<a
href={linkToFilterTag(cur.concat([kLower]).map(encodeTag).join('+'))}
className="link mr2 tag-include"
>
<a href={linkToFilterTag(cur.concat([kLower]))} className="link mr2 tag-include">
</a>
) : (
<a
href={linkToFilterTag(
cur
.filter((t) => t !== kLower)
.map(encodeTag)
.join('+'),
)}
href={linkToFilterTag(cur.filter((t) => t !== kLower))}
className="link mr2 tag-exclude"
>
Expand All @@ -210,7 +201,7 @@ function renderLinks(curtags: string[], tagcloud: TagCloud) {

return (
<React.Fragment key={tag}>
<a href={linkToFilterTag(encodeTag(tag))} className="link tag mr1" style={style}>
<a href={linkToFilterTag([tag])} className="link tag mr1" style={style}>
{tag}
</a>
{includeExcludeLink}
Expand Down
39 changes: 39 additions & 0 deletions frontend/src/urlBuild.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { App } from './globals';
import { curQuerystring, encodeTag, fromNullableStr } from './util';

const PRESERVED_PARAMS = ['query', 'sort', 'order'];

/**
* Builds a URL to the bookmark listing for the given tags, keeping the
* current filter/shared axis (mirrors pageRouteFor in Handler/User.hs) and
* the query/sort/order params from the current URL. Paging cursor params
* are intentionally dropped, resetting paging.
*/
export function buildTagUrl(a: App, tags: string[]): string {
const base = fromNullableStr(a.userR);
const tagSegment = tags.length ? `/t:${tags.map(encodeTag).join('+')}` : '';
return `${base}${axisSegment(a)}${tagSegment}${preservedQueryString()}`;
}

function axisSegment(a: App): string {
if (a.dat.sharedp === 'private') return '/private';
if (a.dat.sharedp === 'public') return '/public';
switch (a.dat.filter?.tag) {
case 'FilterUnread':
return '/unread';
case 'FilterUntagged':
return '/untagged';
case 'FilterStarred':
return '/starred';
default:
return '';
}
}

function preservedQueryString(): string {
const qs = curQuerystring().filter(([k]) => PRESERVED_PARAMS.includes(k));
if (qs.length === 0) return '';
return (
'?' + qs.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v ?? '')}`).join('&')
);
}
3 changes: 2 additions & 1 deletion package.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: espial
synopsis: Espial is an open-source, web-based bookmarking server.
version: "0.0.41"
version: "0.0.42"
description: ! "
Espial is an open-source, web-based bookmarking server.

Expand Down Expand Up @@ -215,6 +215,7 @@ executables:
- espial
- optparse-generic >= 1.2.3
- optparse-applicative
- clientsession

# Test suite
tests:
Expand Down
17 changes: 13 additions & 4 deletions src/Foundation.hs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import ClassyPrelude.Yesod qualified as CP (Lang)
import Control.Concurrent (ThreadId)
import Data.ByteString.Char8 qualified as BS8
import Data.CaseInsensitive qualified as CI
import Data.Char (isSpace)
import Data.IP (fromSockAddr)
import Data.List (dropWhileEnd)
import Data.Map.Strict qualified as Map
import Data.Text.Encoding qualified as TE
import Data.Text.Encoding.Error qualified as TEE
Expand All @@ -17,6 +19,7 @@ import Database.Persist.Sql (ConnectionPool, runSqlPool)
import Import.NoFoundation
import Network.Wai qualified as Wai
import PathPiece ()
import System.Environment (lookupEnv)
import Text.Hamlet (hamletFile)
import Yesod.Auth.Message
import Yesod.Core.Types
Expand Down Expand Up @@ -63,6 +66,7 @@ data App = App
-- | Serializes DB write transactions to avoid SQLite `SQLITE_BUSY`/snapshot conflicts under concurrent writers.
appDBWriteLock :: DBWriteLock
}

mkYesodData "App" $(parseRoutesFile "config/routes")

deriving instance Generic (Route App)
Expand Down Expand Up @@ -107,12 +111,17 @@ instance Yesod App where

makeSessionBackend :: App -> IO (Maybe SessionBackend)
makeSessionBackend App {appSettings} = do
backend <-
defaultClientSessionBackend
session_timeout_minutes
"config/client_session_key.aes"
envKey <- lookupEnv "CLIENT_SESSION_KEY"
backend <- case envKey of
Just key | not (null (stripString key)) -> envClientSessionBackend session_timeout_minutes "CLIENT_SESSION_KEY"
_ ->
defaultClientSessionBackend
session_timeout_minutes
"config/client_session_key.aes"
maybeSSLOnly $ pure (Just backend)
where
stripString :: String -> String
stripString = dropWhile isSpace . dropWhileEnd isSpace
maybeSSLOnly =
if appSSLOnly appSettings
then sslOnlySessions
Expand Down
Loading
Loading