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
72 changes: 71 additions & 1 deletion __tests__/common/standardizeURL.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { domainAllowedSearchParams, standardizeURL } from '../../src/common';
import {
domainAllowedSearchParams,
getUrlDedupVariants,
getUrlTrailingSlashVariants,
standardizeURL,
} from '../../src/common';

describe('standardizeURL', () => {
it('should keep url without query', () => {
Expand All @@ -13,6 +18,14 @@ describe('standardizeURL', () => {
expect(canonicalUrl).toBe('https://test.com/posts/1');
});

it('should preserve a trailing slash on the submitted url', () => {
const { url, canonicalUrl } = standardizeURL(
'https://github.com/versity/versitygw/',
);
expect(url).toBe('https://github.com/versity/versitygw/');
expect(canonicalUrl).toBe('https://github.com/versity/versitygw/');
});

it('should clean query params', () => {
const { url, canonicalUrl } = standardizeURL(
'https://test.com/posts/1?utm_source=google',
Expand Down Expand Up @@ -51,3 +64,60 @@ describe('standardizeURL', () => {
});
});
});

describe('getUrlTrailingSlashVariants', () => {
it('should return both slash forms for a url without a trailing slash', () => {
expect(
getUrlTrailingSlashVariants('https://github.com/versity/versitygw'),
).toEqual([
'https://github.com/versity/versitygw',
'https://github.com/versity/versitygw/',
]);
});

it('should return both slash forms for a url with a trailing slash', () => {
expect(
getUrlTrailingSlashVariants('https://github.com/versity/versitygw/'),
).toEqual([
'https://github.com/versity/versitygw',
'https://github.com/versity/versitygw/',
]);
});

it('should collapse multiple trailing slashes', () => {
expect(
getUrlTrailingSlashVariants('https://github.com/versity/versitygw//'),
).toEqual([
'https://github.com/versity/versitygw',
'https://github.com/versity/versitygw/',
]);
});

it('should preserve query params on both forms', () => {
expect(
getUrlTrailingSlashVariants('https://test.com/posts/1/?sk=google'),
).toEqual([
'https://test.com/posts/1?sk=google',
'https://test.com/posts/1/?sk=google',
]);
});

it('should not toggle a root url', () => {
expect(getUrlTrailingSlashVariants('https://github.com/')).toEqual([
'https://github.com/',
]);
});
});

describe('getUrlDedupVariants', () => {
it('should cross www and trailing-slash variants', () => {
expect(
getUrlDedupVariants('https://github.com/versity/versitygw/'),
).toEqual([
'https://github.com/versity/versitygw',
'https://github.com/versity/versitygw/',
'https://www.github.com/versity/versitygw',
'https://www.github.com/versity/versitygw/',
]);
});
});
31 changes: 31 additions & 0 deletions src/common/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,37 @@ export const getUrlWwwVariants = (url: string): string[] => {
return [url, www ? `${scheme}${rest}` : `${scheme}www.${rest}`];
};

// The same article is often reachable with or without a trailing slash.
// Return the url alongside its trailing-slash-toggled form so dedup lookups
// can match either without rewriting the url we persist. Root-only urls
// (`https://host/`) are left untouched.
export const getUrlTrailingSlashVariants = (url: string): string[] => {
const [path, params] = url.split('?');
const trimmed = path.replace(/^(https?:\/\/[^/]+\/.+?)\/+$/i, '$1');

// No real (non-root) path to toggle.
if (!/^https?:\/\/[^/]+\/.+/i.test(trimmed)) {
return [url];
}

const suffix = params !== undefined ? `?${params}` : '';
return [`${trimmed}${suffix}`, `${trimmed}/${suffix}`];
};

// All url variants we treat as the same article for dedup lookups: every
// www.-toggled form crossed with its trailing-slash-toggled form.
export const getUrlDedupVariants = (url: string): string[] => {
const variants = new Set<string>();

for (const wwwVariant of getUrlWwwVariants(url)) {
for (const slashVariant of getUrlTrailingSlashVariants(wwwVariant)) {
variants.add(slashVariant);
}
}

return [...variants];
};

export const standardizeURL = (
inputUrl: string,
): { url: string; canonicalUrl: string } => {
Expand Down
10 changes: 5 additions & 5 deletions src/common/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
WelcomePost,
} from '../entity';
import { ForbiddenError, ValidationError } from 'apollo-server-errors';
import { getUrlWwwVariants, isValidHttpUrl, standardizeURL } from './links';
import { getUrlDedupVariants, isValidHttpUrl, standardizeURL } from './links';
import {
findMarkdownTag,
markdown,
Expand Down Expand Up @@ -939,8 +939,8 @@ export const getExistingPost = async (
.createQueryBuilder(Post, 'post')
.select(['post.id', 'post.deleted', 'post.visible'])
.where([
{ canonicalUrl: In(getUrlWwwVariants(canonicalUrl)) },
{ url: In(getUrlWwwVariants(url)) },
{ canonicalUrl: In(getUrlDedupVariants(canonicalUrl)) },
{ url: In(getUrlDedupVariants(url)) },
])
.getOne();

Expand Down Expand Up @@ -989,8 +989,8 @@ export const findPostByUrl = async <T extends keyof ArticlePost>(
]);
} else {
queryBuilder = queryBuilder.andWhere([
{ canonicalUrl: In(getUrlWwwVariants(canonicalUrl)) },
{ url: In(getUrlWwwVariants(cleanUrl)) },
{ canonicalUrl: In(getUrlDedupVariants(canonicalUrl)) },
{ url: In(getUrlDedupVariants(cleanUrl)) },
]);
}

Expand Down
Loading