Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97f5d19ce4 | ||
|
|
1b4aba54d6 | ||
|
|
b30285fe70 | ||
|
|
20209bcad5 | ||
|
|
74902234b3 | ||
|
|
d62bddc3aa | ||
|
|
42c13ea106 | ||
|
|
a4e9ce16a7 | ||
|
|
4f89a69ee3 | ||
|
|
147dcdf2f1 | ||
|
|
d4e20d9b98 | ||
|
|
ec8c771733 | ||
|
|
3784e8729b | ||
|
|
69d62eaa5c | ||
|
|
767f9c508b | ||
|
|
103ce6f207 | ||
|
|
5267dab236 | ||
|
|
ebf2bf660a | ||
|
|
c0f3523a9c | ||
|
|
6f5021638c | ||
|
|
67f7750157 | ||
|
|
9e306eb85e | ||
|
|
b2da08d52d | ||
|
|
d7c13ecc19 | ||
|
|
d396b356be | ||
|
|
e23dfe4474 | ||
|
|
41e7c5e206 | ||
|
|
f685eaebd7 | ||
|
|
47e44ec5e9 | ||
|
|
cd7dc5f981 | ||
|
|
a724e5bc87 |
@@ -54,27 +54,10 @@ If the app shows "No Archives Found" and logs `EACCES: permission denied`:
|
||||
chmod -R 755 /path/to/archives
|
||||
```
|
||||
2. **SELinux (Fedora/RHEL/CentOS)**: Use the `:z` flag in your volume mount as shown above.
|
||||
3. **User Mapping**: The container runs as the non-root `node` user (UID 1000).
|
||||
If your archives are readable only by another account, run as that user
|
||||
instead — the container needs to *list* the archive directory, so `--x`
|
||||
(traverse-only) permissions are not enough:
|
||||
3. **User Mapping**: You can force the container to run as your host user:
|
||||
```bash
|
||||
docker run --user $(stat -c '%u:%g' /path/to/archives) ...
|
||||
docker run --user $(id -u):$(id -g) ...
|
||||
```
|
||||
In Compose:
|
||||
```yaml
|
||||
services:
|
||||
instaarchive:
|
||||
user: "1234:1234" # a UID that can read your archives
|
||||
```
|
||||
|
||||
### Archive Index
|
||||
|
||||
On first start the server walks the archive root once and caches the result,
|
||||
keyed by directory mtime. This matters on network storage: for a 110k-file
|
||||
archive root, listing went from ~52s per request to ~0.1s. Mount a volume at
|
||||
`/cache` (or set `CACHE_DIR`) so the index survives restarts, otherwise it is
|
||||
rebuilt on every start.
|
||||
|
||||
## Supported Archive Structure
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "instaarchive-viewer",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "instaarchive-viewer",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "instaarchive-viewer",
|
||||
"private": true,
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||
|
||||
@@ -16,23 +16,7 @@ const PORT = process.env.PORT || 3001;
|
||||
const ARCHIVES_DIR = path.resolve(process.env.ARCHIVES_DIR || path.join(__dirname, '_sample-archives'));
|
||||
|
||||
console.log(`[Server] Initializing...`);
|
||||
/**
|
||||
* Describe the running user without assuming it exists in /etc/passwd.
|
||||
*
|
||||
* `os.userInfo()` throws ENOENT for a UID with no passwd entry, which is
|
||||
* exactly what happens when the container is started with `--user 1234:1234`
|
||||
* (as the deployment docs suggest) — previously crashing the server at boot.
|
||||
*/
|
||||
const describeUser = () => {
|
||||
try {
|
||||
const info = os.userInfo();
|
||||
return `${info.username} (UID: ${info.uid}, GID: ${info.gid})`;
|
||||
} catch {
|
||||
return `UID: ${typeof process.getuid === 'function' ? process.getuid() : '?'}, GID: ${typeof process.getgid === 'function' ? process.getgid() : '?'} (no passwd entry)`;
|
||||
}
|
||||
};
|
||||
|
||||
console.log(`[Server] Running as user: ${describeUser()}`);
|
||||
console.log(`[Server] Running as user: ${os.userInfo().username} (UID: ${os.userInfo().uid}, GID: ${os.userInfo().gid})`);
|
||||
console.log(`[Server] Environment ARCHIVES_DIR: ${process.env.ARCHIVES_DIR}`);
|
||||
console.log(`[Server] Resolved ARCHIVES_DIR: ${ARCHIVES_DIR}`);
|
||||
|
||||
@@ -130,7 +114,7 @@ app.get('/api/archives', (req, res) => {
|
||||
res.json(archives);
|
||||
} catch (err: any) {
|
||||
if (err.code === 'EACCES') {
|
||||
console.error(`[API] Permission Denied! The server (${describeUser()}) cannot read ${ARCHIVES_DIR}.`);
|
||||
console.error(`[API] Permission Denied! The server (UID ${os.userInfo().uid}) cannot read ${ARCHIVES_DIR}.`);
|
||||
console.error(`[API] Hint: If using Linux/Docker, check folder permissions (chmod 755) or SELinux context (append :z to your volume mount).`);
|
||||
} else {
|
||||
console.error('[API] Error listing archives:', err);
|
||||
|
||||
@@ -3,39 +3,39 @@ import { classifyDirectory, groupArchiveDirectories } from './archive-grouping';
|
||||
|
||||
describe('classifyDirectory', () => {
|
||||
it('treats a bare profile directory as the base', () => {
|
||||
expect(classifyDirectory('4utumn07')).toEqual({
|
||||
owner: '4utumn07',
|
||||
source: { kind: 'posts', dir: '4utumn07' },
|
||||
expect(classifyDirectory('0ct0ber19')).toEqual({
|
||||
owner: '0ct0ber19',
|
||||
source: { kind: 'posts', dir: '0ct0ber19' },
|
||||
});
|
||||
});
|
||||
|
||||
it('recognises a reels sidecar', () => {
|
||||
expect(classifyDirectory('4utumn07 - reels')).toEqual({
|
||||
owner: '4utumn07',
|
||||
source: { kind: 'reels', dir: '4utumn07 - reels' },
|
||||
expect(classifyDirectory('0ct0ber19 - reels')).toEqual({
|
||||
owner: '0ct0ber19',
|
||||
source: { kind: 'reels', dir: '0ct0ber19 - reels' },
|
||||
});
|
||||
});
|
||||
|
||||
it('recognises a stories sidecar', () => {
|
||||
expect(classifyDirectory('story - dawn_petal')).toEqual({
|
||||
owner: 'dawn_petal',
|
||||
source: { kind: 'stories', dir: 'story - dawn_petal' },
|
||||
expect(classifyDirectory('story - cher_ryppo')).toEqual({
|
||||
owner: 'cher_ryppo',
|
||||
source: { kind: 'stories', dir: 'story - cher_ryppo' },
|
||||
});
|
||||
});
|
||||
|
||||
it('splits highlight owner from title', () => {
|
||||
const { owner, source } = classifyDirectory('story highlights - 4utumn07 - Sunstory');
|
||||
expect(owner).toBe('4utumn07');
|
||||
const { owner, source } = classifyDirectory('story highlights - 0ct0ber19 - Heestory');
|
||||
expect(owner).toBe('0ct0ber19');
|
||||
expect(source.kind).toBe('highlight');
|
||||
expect(source.title).toBe('Sunstory');
|
||||
expect(source.title).toBe('Heestory');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['story highlights - theoldlyricmuseinsta - 💙1999-2005 era', 'theoldlyricmuseinsta', '💙1999-2005 era'],
|
||||
['story highlights - member_theworld - [Bracket]', 'member_theworld', '[Bracket]'],
|
||||
['story highlights - official_band - Tour Schedule', 'official_band', 'Tour Schedule'],
|
||||
['story highlights - 4utumn07 - Sketching⠀', '4utumn07', 'Sketching⠀'],
|
||||
['story highlights - official_band - A.B.C', 'official_band', 'A.B.C'],
|
||||
['story highlights - theoldtaylorswiftinsta - 💙2014-1989 era', 'theoldtaylorswiftinsta', '💙2014-1989 era'],
|
||||
['story highlights - heejin_theworld - [Dall]', 'heejin_theworld', '[Dall]'],
|
||||
['story highlights - official_artms - Cosmo Schedule', 'official_artms', 'Cosmo Schedule'],
|
||||
['story highlights - 0ct0ber19 - Drawheeing⠀', '0ct0ber19', 'Drawheeing⠀'],
|
||||
['story highlights - official_artms - G.C.I', 'official_artms', 'G.C.I'],
|
||||
])('handles real-world title %s', (dir, owner, title) => {
|
||||
const result = classifyDirectory(dir);
|
||||
expect(result.owner).toBe(owner);
|
||||
@@ -57,25 +57,25 @@ describe('classifyDirectory', () => {
|
||||
|
||||
describe('groupArchiveDirectories', () => {
|
||||
const dirs = [
|
||||
'4utumn07',
|
||||
'4utumn07 - reels',
|
||||
'story - 4utumn07',
|
||||
'story highlights - 4utumn07 - Sunstory',
|
||||
'story highlights - 4utumn07 - Sketching⠀',
|
||||
'kestrelsings',
|
||||
'0ct0ber19',
|
||||
'0ct0ber19 - reels',
|
||||
'story - 0ct0ber19',
|
||||
'story highlights - 0ct0ber19 - Heestory',
|
||||
'story highlights - 0ct0ber19 - Drawheeing⠀',
|
||||
'carlyraejepsen',
|
||||
];
|
||||
|
||||
it('folds sidecars into their base profile', () => {
|
||||
const groups = groupArchiveDirectories(dirs);
|
||||
expect([...groups.keys()].sort()).toEqual(['4utumn07', 'kestrelsings']);
|
||||
expect(groups.get('4utumn07')).toHaveLength(5);
|
||||
expect(groups.get('kestrelsings')).toHaveLength(1);
|
||||
expect([...groups.keys()].sort()).toEqual(['0ct0ber19', 'carlyraejepsen']);
|
||||
expect(groups.get('0ct0ber19')).toHaveLength(5);
|
||||
expect(groups.get('carlyraejepsen')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('orders sources posts, reels, stories, then highlights by title', () => {
|
||||
const sources = groupArchiveDirectories(dirs).get('4utumn07')!;
|
||||
const sources = groupArchiveDirectories(dirs).get('0ct0ber19')!;
|
||||
expect(sources.map(s => s.kind)).toEqual(['posts', 'reels', 'stories', 'highlight', 'highlight']);
|
||||
expect(sources.slice(3).map(s => s.title)).toEqual(['Sketching⠀', 'Sunstory']);
|
||||
expect(sources.slice(3).map(s => s.title)).toEqual(['Drawheeing⠀', 'Heestory']);
|
||||
});
|
||||
|
||||
it('still groups a sidecar whose base profile is missing', () => {
|
||||
|
||||
@@ -18,10 +18,10 @@ export interface ArchiveSource {
|
||||
/**
|
||||
* Sidecar directories sit next to the profile directory they belong to:
|
||||
*
|
||||
* 4utumn07 -> posts (base)
|
||||
* 4utumn07 - reels -> reels
|
||||
* story - 4utumn07 -> stories
|
||||
* story highlights - 4utumn07 - Sunstory -> highlight "Sunstory"
|
||||
* 0ct0ber19 -> posts (base)
|
||||
* 0ct0ber19 - reels -> reels
|
||||
* story - 0ct0ber19 -> stories
|
||||
* story highlights - 0ct0ber19 - Heestory -> highlight "Heestory"
|
||||
*
|
||||
* Instagram usernames cannot contain spaces, so matching the username as a
|
||||
* run of non-space characters reliably separates it from a highlight title
|
||||
|
||||
@@ -3,10 +3,10 @@ import { parseArchiveFilename, scopedPostId } from './archive-patterns';
|
||||
|
||||
describe('parseArchiveFilename — Instagram export format', () => {
|
||||
it('parses a single-image post', () => {
|
||||
expect(parseArchiveFilename('2023-04-19_4utumn07 - CrORBIcJJbM.mp4')).toEqual({
|
||||
expect(parseArchiveFilename('2023-04-19_0ct0ber19 - CrORBIcJJbM.mp4')).toEqual({
|
||||
postId: 'CrORBIcJJbM',
|
||||
date: '2023-04-19',
|
||||
username: '4utumn07',
|
||||
username: '0ct0ber19',
|
||||
index: 1,
|
||||
ext: 'mp4',
|
||||
isStory: false,
|
||||
@@ -14,7 +14,7 @@ describe('parseArchiveFilename — Instagram export format', () => {
|
||||
});
|
||||
|
||||
it('parses a carousel slide index', () => {
|
||||
const parsed = parseArchiveFilename('2023-04-12_4utumn07 - Cq8LrxSJAJE - 3.jpg');
|
||||
const parsed = parseArchiveFilename('2023-04-12_0ct0ber19 - Cq8LrxSJAJE - 3.jpg');
|
||||
expect(parsed).toMatchObject({ postId: 'Cq8LrxSJAJE', index: 3, ext: 'jpg' });
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('parseArchiveFilename — Instagram export format', () => {
|
||||
});
|
||||
|
||||
it('parses caption sidecar files', () => {
|
||||
expect(parseArchiveFilename('2023-04-12_4utumn07 - Cq8LrxSJAJE.txt')).toMatchObject({
|
||||
expect(parseArchiveFilename('2023-04-12_0ct0ber19 - Cq8LrxSJAJE.txt')).toMatchObject({
|
||||
postId: 'Cq8LrxSJAJE',
|
||||
ext: 'txt',
|
||||
});
|
||||
@@ -38,8 +38,8 @@ describe('parseArchiveFilename — Instagram export format', () => {
|
||||
|
||||
it('parses the story sidecar layout (date_user - N - shortcode)', () => {
|
||||
// Files in `story - <user>` carry a per-day ordinal before the shortcode.
|
||||
const parsed = parseArchiveFilename('2025-10-26_4utumn07 - 2 - DQRuDx9iW5Q.jpg', 'stories');
|
||||
expect(parsed).toMatchObject({ date: '2025-10-26', username: '4utumn07', ext: 'jpg' });
|
||||
const parsed = parseArchiveFilename('2025-10-26_0ct0ber19 - 2 - DQRuDx9iW5Q.jpg', 'stories');
|
||||
expect(parsed).toMatchObject({ date: '2025-10-26', username: '0ct0ber19', ext: 'jpg' });
|
||||
expect(parsed!.postId).toContain('DQRuDx9iW5Q');
|
||||
});
|
||||
|
||||
@@ -70,9 +70,9 @@ describe('parseArchiveFilename — Instaloader format', () => {
|
||||
|
||||
describe('parseArchiveFilename — story highlights', () => {
|
||||
it('parses the dateless highlight layout', () => {
|
||||
expect(parseArchiveFilename('4utumn07 - C5dQPEYpd9W.mp4', 'highlight')).toMatchObject({
|
||||
expect(parseArchiveFilename('0ct0ber19 - C5dQPEYpd9W.mp4', 'highlight')).toMatchObject({
|
||||
postId: 'C5dQPEYpd9W',
|
||||
username: '4utumn07',
|
||||
username: '0ct0ber19',
|
||||
ext: 'mp4',
|
||||
isStory: false,
|
||||
});
|
||||
@@ -94,7 +94,7 @@ describe('parseArchiveFilename — story highlights', () => {
|
||||
});
|
||||
|
||||
describe('parseArchiveFilename — non-matching files', () => {
|
||||
it.each(['4utumn07.jpg', 'profile_pic.jpg', 'README.md', 'no-separator.png'])(
|
||||
it.each(['0ct0ber19.jpg', 'profile_pic.jpg', 'README.md', 'no-separator.png'])(
|
||||
'returns null for %s',
|
||||
name => expect(parseArchiveFilename(name)).toBeNull(),
|
||||
);
|
||||
@@ -106,8 +106,8 @@ describe('scopedPostId', () => {
|
||||
});
|
||||
|
||||
it('namespaces sidecar ids by directory', () => {
|
||||
expect(scopedPostId('C5dQ', 'highlight', 'story highlights - u - Sunstory'))
|
||||
.toBe('story highlights - u - Sunstory/C5dQ');
|
||||
expect(scopedPostId('C5dQ', 'highlight', 'story highlights - u - Heestory'))
|
||||
.toBe('story highlights - u - Heestory/C5dQ');
|
||||
});
|
||||
|
||||
it('keeps the same shortcode distinct across sources', () => {
|
||||
|
||||
Reference in New Issue
Block a user