code:
:root {
	--main: #f44336;
	--border: #e5e5e5;
	--colorLink: #999;
	--archiveHeaderBg: rgba(255, 255, 255, 0.95);

	@media (prefers-color-scheme: dark) {
		--border: #191919;
		--archiveHeaderBg: rgba(41, 41, 41, 0.95);
	}
}

@header-height: 60px;
@screen-sm-max: 600px;
@screen-sm-header-height: 50px;

#shiori-archive-header {
	top: 0;
	left: 0;
	right: 0;
	height: @header-height;
	box-sizing: border-box;
	position: fixed;
	padding: 0 16px;
	display: flex;
	flex-flow: row wrap;
	align-items: center;
	font-size: 16px;
	border-bottom: 1px solid var(--border);
	background-color: var(--archiveHeaderBg);
	z-index: 9999999999;

	* {
		border-width: 0;
		box-sizing: border-box;
		font-family: "Source Sans Pro", sans-serif;
		margin: 0;
		padding: 0;
	}

	> *:not(:last-child) {
		margin-right: 8px;
	}

	> .spacer {
		flex: 1;
	}

	#shiori-logo {
		font-size: 2em;
		font-weight: 100;
		color: var(--main);

		span {
			margin-right: 8px;
		}
	}

	a {
		display: block;
		color: var(--colorLink);
		text-decoration: underline;

		&:hover,
		&:focus {
			color: var(--main);
		}
	}

	@media (max-width: @screen-sm-max) {
		font-size: 14px;
		height: @screen-sm-header-height;

		#shiori-logo {
			font-size: 1.5em;
		}
	}
}

body.shiori-archive-content {
	margin-top: @header-height !important;

	@media (max-width: @screen-sm-max) {
		margin-top: @screen-sm-header-height !important;
	}
}

repo_name: orochi/shiori

license: mit


code:
.bookmark {
	display: flex;
	flex-flow: column nowrap;
	min-width: 0;
	border: 1px solid var(--border);
	background-color: var(--contentBg);
	height: 100%;
	position: relative;

	&:hover,
	&:focus {
		.bookmark-menu > a {
			display: block;
		}
	}

	&.selected {
		background-color: var(--selectedBg);
	}

	.bookmark-selector {
		position: absolute;
		top: 0;
		left: 0;
		width: 100%;
		height: 100%;
		z-index: 9;
	}

	.bookmark-link {
		display: block;
		cursor: default;

		&[href] {
			cursor: pointer;

			&:hover,
			&:focus {
				.title {
					color: var(--main);
				}
			}
		}

		span.thumbnail {
			width: 100%;
			height: 200px;
			display: block;
			background-size: cover;
			background-repeat: no-repeat;
			background-position: center center;
			margin-bottom: 8px;
			border-bottom: 1px solid var(--border);
		}

		.id {
			color: var(--color);
			border: 1px solid var(--border);
			background-color: var(--contentBg);
			font-size: 0.7em;
			font-weight: bold;
			left: -1px;
			top: -1px;
			position: absolute;
			padding: 0px 0.3em;
			opacity: 0.7;
		}

		.title {
			text-overflow: ellipsis;
			word-wrap: break-word;
			overflow: hidden;
			font-size: 1.2em;
			line-height: 1.3em;
			max-height: 5.2em;
			font-weight: 600;
			padding: 0 16px;
			color: var(--color);

			&:first-child {
				margin-top: 16px;
			}

			i {
				color: var(--colorLink);
				margin-left: 4px;
				font-size: 14px;
			}
		}

		.excerpt {
			color: var(--color);
			margin-top: 8px;
			padding: 0 16px;
			text-overflow: ellipsis;
			word-wrap: break-word;
			overflow: hidden;
			font-size: 0.9em;
			line-height: 1.5em;
			max-height: 10.5em;
		}
	}

	.bookmark-tags {
		display: flex;
		flex-flow: row wrap;
		margin: 8px 0 -4px;
		padding: 0 8px;

		a {
			margin: 4px;
			padding: 4px 8px;
			font-size: 0.8em;
			font-weight: 600;
			border: 1px solid var(--border);
			border-radius: 4px;
			color: var(--colorLink);
			background-color: var(--contentBg);

			&:hover,
			&:focus {
				color: var(--main);
			}
		}
	}

	.bookmark-menu {
		padding: 8px 16px 16px;
		display: flex;
		flex-flow: row nowrap;
		min-width: 0;
		min-height: 0;
		align-items: center;

		a {
			color: var(--colorLink);
			flex-shrink: 0;
			opacity: 0.8;
			display: none;
			font-size: 0.9em;

			&:not(:last-child) {
				margin-right: 12px;
			}

			&:hover,
			&:focus {
				color: var(--main);
				opacity: 1;
			}
		}

		.url {
			flex: 1 0;
			opacity: 1;
			display: block;
			white-space: nowrap;
			overflow: hidden;
			text-overflow: ellipsis;
			line-height: 21px;

			&:not([href]) {
				cursor: default;
				color: var(--colorLink);
			}
		}

		@media (max-width: 600px) {
			a {
				display: block;
			}
		}
	}
}

.bookmark.list {
	border-top-width: 0;
	border-bottom-width: 1px;
	padding: 16px 24px 16px 100px;

	&:first-child {
		border-top-width: 1px;
	}

	.bookmark-link {
		span.thumbnail {
			position: absolute;
			top: 0;
			left: 0;
			width: 100px;
			height: 100%;
			margin-bottom: 0;
			border-bottom: 0px;
			border-right: 1px solid var(--border);
		}

		.title {
			margin: 0;
			padding-left: 24px;
		}
	}

	.excerpt,
	> .spacer {
		display: none;
	}

	.bookmark-tags {
		padding-left: 16px;
		padding-right: 0;
	}

	.bookmark-menu {
		padding: 8px 0 0 24px;
		align-items: flex-end;
	}

	&.no-thumbnail {
		padding-left: 16px;
		padding-right: 16px;

		.bookmark-link .title {
			padding: 0;
			margin-bottom: 4px;
		}

		.excerpt {
			margin-top: 0;
			margin-bottom: 4px;
			padding: 0;
			display: block;
		}

		.bookmark-tags {
			padding-left: 0;
			margin: 0 -4px 0;
		}

		.bookmark-menu {
			padding-top: 0;
			padding-left: 0;
		}
	}

	@media (max-width: 600px) {
		padding: 8px 16px 8px 70px;
		border-width: 0 !important;
		border-bottom-width: 1px !important;

		.bookmark-link {
			span.thumbnail {
				width: 70px;
			}

			.title {
				font-size: 1.1em;
				font-weight: 500;
				padding-left: 16px;
			}
		}

		.bookmark-tags {
			padding-left: 8px;
		}

		.bookmark-menu {
			padding-left: 16px;
		}
	}
}


code:
:root {
	--bg: #eee;
	--sidebarBg: #292929;
	--sidebarHoverBg: #232323;
	--headerBg: #fff;
	--contentBg: #fff;
	--border: #e5e5e5;
	--color: #232323;
	--colorLink: #999;
	--colorSidebar: #fff;
	--main: #f44336;
	--errorColor: #f44336;
	--selectedBg: #ffe7e5;

	@media (prefers-color-scheme: dark) {
		&:root {
			--bg: #1f1f1f;
			--headerBg: #292929;
			--contentBg: #292929;
			--border: #191919;
			--color: #fff;
			--selectedBg: #261918;
		}
	}
}

&.night {
	--bg: #1f1f1f;
	--headerBg: #292929;
	--contentBg: #292929;
	--border: #191919;
	--color: #fff;
	--selectedBg: #261918;
}

* {
	border-width: 0;
	box-sizing: border-box;
	font-family: "Source Sans Pro", sans-serif;
	margin: 0;
	padding: 0;
	text-decoration: none;
}

body {
	background-color: var(--bg);
}

a {
	cursor: pointer;
}

.spacer {
	flex: 1;
}

#login-scene {
	height: 100%;
	height: 100dvh;
	padding: 16px;
	overflow: auto;
	display: flex;
	align-items: center;
	flex-flow: column nowrap;
	background-color: var(--bg);

	> .error-message {
		width: 100%;
		max-width: 400px;
		font-size: 1em;
		background-color: var(--contentBg);
		border: 1px solid var(--border);
		padding: 16px;
		margin-top: auto;
		margin-bottom: 16px;
		text-align: center;
		color: var(--errorColor);
	}

	#login-box {
		width: 100%;
		max-width: 400px;
		margin-bottom: auto;
		background-color: var(--contentBg);
		display: flex;
		flex-flow: column nowrap;
		border: 1px solid var(--border);
		flex-shrink: 0;

		&:first-child {
			margin-top: auto;
		}

		#logo-area {
			display: flex;
			align-items: center;
			flex-flow: column nowrap;
			padding: 16px;
			background-color: var(--main);
			border-bottom: 1px solid var(--border);
			flex-shrink: 0;

			#logo {
				font-size: 3em;
				font-weight: 100;
				color: var(--contentBg);

				span {
					margin-right: 8px;
				}
			}

			#tagline {
				font-weight: 500;
				margin-top: 4px;
				color: var(--contentBg);
				text-align: center;
			}
		}

		#input-area {
			padding: 16px;
			display: grid;
			grid-gap: 16px;
			grid-template-columns: auto 1fr;
			justify-content: baseline;
			align-items: center;
			border-bottom: 1px solid var(--border);

			> label {
				color: var(--color);
			}

			> input {
				color: var(--color);
				padding: 8px;
				background-color: var(--contentBg);
				border: 1px solid var(--border);
				min-width: 0;
				font-size: 1em;
			}

			.checkbox-field {
				grid-column: 1 / span 2;
				display: flex;
				flex-flow: row nowrap;
				align-items: center;
				justify-content: center;
				cursor: pointer;

				&:hover,
				&:focus {
					text-decoration: underline;
					text-decoration-color: var(--main);
				}

				> input[type="checkbox"] {
					margin-right: 8px;
				}
			}
		}

		#button-area {
			display: flex;
			flex-flow: row nowrap;
			padding: 16px;
			justify-content: center;

			a {
				color: var(--color);
				text-transform: uppercase;
				text-align: center;
				font-weight: 600;
				cursor: default;

				&.button {
					cursor: pointer;

					&:hover,
					&:focus {
						color: var(--main);
					}
				}
			}
		}
	}
}

#main-scene {
	min-height: 100%;
	min-height: 100dvh;
	padding-top: 60px;
	padding-left: 60px;
	background-color: var(--bg);

	#main-sidebar {
		top: 0;
		left: 0;
		width: 60px;
		height: 100%;
		height: 100dvh;
		position: fixed;
		display: flex;
		flex-flow: column nowrap;
		background-color: var(--sidebarBg);
		z-index: 1;

		a {
			flex-shrink: 0;
			display: block;
			width: 60px;
			line-height: 60px;
			text-align: center;
			font-size: 1em;
			color: var(--colorSidebar);

			&.active {
				cursor: default;
				color: var(--colorSidebar);
				background-color: var(--main);
			}

			&:hover,
			&:focus {
				color: var(--main);
				background-color: var(--sidebarHoverBg);
			}
		}
	}

	.page-header {
		top: 0px;
		left: 60px;
		right: 0px;
		height: 60px;
		position: fixed;
		color: var(--color);
		background-color: var(--headerBg);
		border-bottom: 1px solid var(--border);
		padding: 0 16px;
		z-index: 10;
	}

	h1.page-header {
		line-height: 60px;
		font-size: 1.3em;
		font-weight: 600;
	}

	div.page-header {
		display: flex;
		flex-flow: row nowrap;
		align-items: center;

		p {
			flex: 1 0;
			font-size: 1.3em;
			font-weight: 600;
			line-height: 60px;
			color: var(--color);
		}

		input[type="text"] {
			flex: 1 0;
			min-width: 0;
			margin-right: 8px;
			font-size: 1.1em;
			font-weight: 500;
			line-height: 59px;
			color: var(--color);
			background-color: var(--contentBg);

			&::placeholder {
				color: var(--colorLink);
			}
		}

		a {
			display: block;
			width: 24px;
			line-height: 24px;
			color: var(--colorLink);
			text-align: center;

			&:not(:last-child) {
				margin-right: 8px;
			}

			&:hover {
				color: var(--main);
			}
		}
	}

	.loading-overlay {
		display: flex;
		flex-flow: column nowrap;
		align-items: center;
		justify-content: center;
		overflow: hidden;
		position: fixed;
		top: 0;
		left: 0;
		width: 100%;
		width: 100dvw;
		height: 100%;
		height: 100dvh;
		z-index: 10001;
		background-color: rgba(0, 0, 0, 0.6);

		i {
			color: var(--colorSidebar);
			font-size: 4em;
			text-align: center;
			width: 80px;
			line-height: 80px;
			position: absolute;
		}
	}

	@media (max-width: 600px) {
		padding-top: 50px;
		padding-left: 0;
		padding-bottom: 50px;

		#main-sidebar {
			top: auto;
			right: 0;
			bottom: 0;
			width: 100%;
			width: 100dvw;
			height: 50px;
			flex-flow: row nowrap;
			border-top: 1px solid var(--border);

			.spacer {
				display: none;
			}

			a {
				width: auto;
				flex: 1 0;
				line-height: 50px;

				&:hover,
				&:focus {
					color: var(--colorSidebar);
					background-color: var(--main);
				}
			}
		}

		.page-header {
			left: 0;
			height: 50px;
		}

		h1.page-header {
			text-align: center;
			font-size: 1em;
			line-height: 50px;
			text-transform: uppercase;
		}

		div.page-header {
			flex-flow: row wrap;

			p {
				flex: 1 0;
				font-size: 1em;
				font-weight: 500;
				line-height: 3em;
				padding: 0;
			}

			input[type="text"] {
				flex: 1 0;
				font-size: 1em;
				font-weight: 500;
				line-height: 3em;
			}

			a {
				display: block;
				width: 24px;
				line-height: 100%;
			}
		}
	}
}

#content-scene {
	padding: 20px;
	display: flex;
	color: var(--color);
	background-color: var(--bg);
	flex-flow: column nowrap;
	align-items: center;

	#header {
		width: 100%;
		padding: 20px;
		max-width: 840px;
		margin-bottom: 16px;
		background-color: var(--contentBg);
		border: 1px solid var(--border);
		display: flex;
		flex-flow: column;
		align-items: center;

		#metadata {
			display: flex;
			flex-flow: row wrap;
			text-align: center;
			font-size: 16px;
			color: var(--colorLink);

			&[v-cloak] {
				visibility: hidden;
			}
		}

		#title {
			padding: 8px 0;
			grid-column-start: 1;
			grid-column-end: -1;
			font-size: 36px;
			font-weight: 700;
			word-break: break-word;
			hyphens: none;
			text-align: center;
		}

		#links {
			display: flex;
			flex-flow: row wrap;

			a {
				padding: 0 4px;
				color: var(--color);
				text-decoration: underline;

				&:hover,
				&:focus {
					color: var(--main);
				}
			}
		}
	}

	#content {
		width: 100%;
		padding: 20px;
		max-width: 840px;
		background-color: var(--contentBg);
		border: 1px solid var(--border);

		* {
			font-size: 18px;
			line-height: 180%;

			&:not(:last-child) {
				margin-bottom: 20px;
			}
		}

		a {
			color: var(--color);
			text-decoration: underline;

			&:hover,
			&:focus {
				color: var(--main);
			}
		}

		pre,
		code {
			overflow: auto;
			border: 1px solid var(--border);
			font-family: "Ubuntu Mono", "Courier New", Courier, monospace;
			font-size: 16px;
		}

		pre {
			padding: 8px;

			> code {
				border: 0;
			}
		}

		ol,
		ul {
			padding-left: 16px;
		}

		img {
			height: auto;
			max-width: 100%;
		}

		table {
			border: 1px solid var(--border);
			border-collapse: collapse;

			tr,
			th,
			td {
				border: 1px solid var(--border);
			}
		}
	}
}

#page-home {
	> .empty-message {
		width: 100%;
		max-width: 400px;
		font-size: 1em;
		background-color: var(--contentBg);
		border: 1px solid var(--border);
		padding: 16px;
		margin: 16px;
		color: var(--errorColor);
	}

	#edit-box {
		background-color: var(--selectedBg);
		border-bottom: 1px solid var(--main);
	}

	#bookmarks-grid {
		display: grid;
		grid-template-rows: min-content;
		grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
		grid-gap: 16px;
		padding: 16px;
		overflow: auto;

		.bookmark {
			align-self: start;
		}

		.pagination-box {
			grid-column-end: -1;
			grid-column-start: 1;
			display: flex;
			flex-flow: row nowrap;
			align-self: start;

			a {
				padding: 8px;
				color: var(--colorLink);

				&:hover,
				&:focus {
					color: var(--main);
				}
			}

			input {
				width: 40px;
				padding: 8px;
				text-align: center;
				font-size: 0.9em;
				color: var(--color);
				border: 1px solid var(--border);
				background-color: var(--contentBg);
				margin: 0 8px;
			}

			p {
				font-size: 0.9em;
				color: var(--colorLink);
				line-height: 37px;
				font-weight: 600;

				&:last-of-type::before {
					content: "/";
					margin-right: 8px;
				}
			}
		}

		&.list {
			grid-gap: 0;
			padding-bottom: 0;
			grid-template-columns: minmax(0, 1000px);

			.pagination-box {
				padding: 16px 0;

				&:first-child {
					padding-top: 0;
				}
			}

			@media (max-width: 600px) {
				padding: 16px 0 0;

				.pagination-box {
					padding: 16px;
				}
			}
		}
	}

	#dialog-tags {
		.custom-dialog-body {
			grid-template-columns: repeat(2, minmax(0, 1fr));

			@media (max-width: 600px) {
				grid-template-columns: minmax(0, 1fr);
			}

			a {
				font-size: 1em;
				color: var(--color);

				span {
					&:last-child {
						font-size: 1em;
						color: var(--colorLink);
						margin-left: 4px;

						&::before {
							content: "(";
							margin-right: 2px;
						}

						&::after {
							content: ")";
							margin-left: 2px;
						}
					}
				}

				&:hover,
				&:focus {
					color: var(--main);
				}
			}
		}
	}
}

#page-setting {
	min-height: 0;
	max-height: 100%;
	display: flex;
	flex-flow: column nowrap;

	.setting-container {
		padding: 8px;
		display: flex;
		overflow: auto;
		flex-flow: column nowrap;
		flex: 1 0;

		&::after {
			content: "";
			display: block;
			min-height: 1px;
		}

		details.setting-group {
			margin: 8px;
			display: block;
			max-width: 350px;
			color: var(--color);
			background-color: var(--contentBg);
			border: 1px solid var(--border);

			@media (max-width: 600px) {
				max-width: 100%;
			}

			summary {
				list-style: none;
				font-weight: 600;
				width: 100%;
				padding: 12px 8px;
				font-size: 1.1em;

				cursor: pointer;

				&:hover {
					color: var(--main);
				}

				&::-webkit-details-marker {
					display: none;
				}

				&::after {
					content: "+";
					margin-left: 8px;
					font-weight: 600;
				}
			}

			&[open] summary {
				border-bottom: 1px solid var(--border);

				::after {
					content: "-";
				}
			}

			div.setting-group-footer {
				padding: 4px 8px;
				display: flex;
				flex-flow: column nowrap;
				align-items: flex-end;
				border-top: 1px solid var(--border);

				> a {
					text-transform: uppercase;
					padding: 8px 4px;
					font-size: 0.9em;
					font-weight: 600;

					&:hover {
						color: var(--main);
					}

					&:focus {
						outline: none;
						color: var(--main);
						border-bottom: 1px dashed var(--main);
					}
				}
			}
		}
	}

	#setting-display,
	#setting-bookmarks {
		display: flex;
		flex-flow: column nowrap;

		&[open] {
			padding-bottom: 8px;

			summary {
				margin-bottom: 8px;
			}
		}

		label {
			padding: 4px 8px;
			color: var(--color);
			display: flex;
			flex-flow: row nowrap;
			align-items: center;
			cursor: pointer;

			&:hover,
			&:focus {
				text-decoration: underline;
				text-decoration-color: var(--main);
			}

			> input[type="checkbox"] {
				margin-right: 8px;
			}
		}
	}

	#setting-accounts {
		summary {
			margin-bottom: 0;
		}

		ul {
			list-style: none;

			li {
				padding: 8px;
				display: flex;
				flex-flow: row nowrap;
				align-items: center;

				&:not(:last-child) {
					border-bottom: 1px solid var(--border);
				}

				p {
					font-size: 1em;
					color: var(--color);
					flex: 1 0;

					span {
						color: var(--colorLink);
					}
				}

				a {
					margin-left: 8px;
					color: var(--colorLink);

					&:hover {
						color: var(--main);
					}
				}
			}
		}
	}
}


code:
:root {
	--dialogHeaderBg: #292929;
	--colorDialogHeader: #fff;
}

.custom-dialog-overlay {
	display: flex;
	flex-flow: column nowrap;
	align-items: center;
	justify-content: center;
	min-width: 0;
	min-height: 0;
	overflow: hidden;
	position: fixed;
	top: 0;
	left: 0;
	width: 100%;
	width: 100dvw;
	height: 100%;
	height: 100dvh;
	z-index: 10001;
	background-color: rgba(0, 0, 0, 0.6);
	padding: 32px;

	.custom-dialog {
		display: flex;
		flex-flow: column nowrap;
		min-height: 0;
		max-height: 100%;
		max-width: 100%;
		width: 400px;
		overflow: auto;
		background-color: var(--contentBg);
		font-size: 16px;
		resize: both;

		.custom-dialog-header {
			padding: 16px;
			color: var(--colorDialogHeader);
			background-color: var(--dialogHeaderBg);
			font-weight: 600;
			font-size: 1em;
			text-transform: uppercase;
			border-bottom: 1px solid var(--border);
		}

		.custom-dialog-body {
			padding: 16px 16px 0;
			display: grid;
			max-height: 100%;
			min-height: 80px;
			min-width: 0;
			overflow: auto;
			font-size: 1em;
			grid-template-columns: max-content 1fr;
			align-content: start;
			align-items: baseline;
			grid-gap: 16px;
			flex-grow: 1;

			&::after {
				content: "";
				display: block;
				min-height: 1px;
				grid-column-end: -1;
				grid-column-start: 1;
			}

			.custom-dialog-content {
				grid-column-end: -1;
				grid-column-start: 1;
				color: var(--color);
				align-self: baseline;
			}

			> label {
				color: var(--color);
				padding: 8px 0;
				font-size: 1em;
			}

			> input[type="text"],
			> input[type="password"],
			> textarea {
				color: var(--color);
				padding: 8px;
				font-size: 1em;
				border: 1px solid var(--border);
				background-color: var(--contentBg);
				min-width: 0;
			}

			.checkbox-field {
				color: var(--color);
				font-size: 1em;
				display: flex;
				flex-flow: row nowrap;
				padding: 0;
				grid-column-start: 1;
				grid-column-end: -1;
				cursor: pointer;

				&:hover,
				&:focus {
					text-decoration: underline;
					text-decoration-color: var(--main);
				}

				> input[type="checkbox"] {
					margin-right: 8px;
				}
			}

			> textarea {
				height: 6em;
				min-height: 37px;
				resize: vertical;
			}

			> .suggestion {
				position: absolute;
				display: block;
				padding: 8px;
				background-color: var(--contentBg);
				border: 1px solid var(--border);
				color: var(--color);
				font-size: 0.9em;
			}
		}

		.custom-dialog-footer {
			padding: 16px;
			display: flex;
			flex-flow: row wrap;
			justify-content: flex-end;
			border-top: 1px solid var(--border);

			> a {
				padding: 0 8px;
				font-size: 0.9em;
				font-weight: 600;
				color: var(--color);
				text-transform: uppercase;

				&:hover,
				&:focus {
					outline: none;
					color: var(--main);
				}
			}

			> i.fa-spinner.fa-spin {
				width: 19px;
				line-height: 19px;
				text-align: center;
				color: var(--color);
			}
		}
	}
}

@media only screen and (max-width: 768px) {
	.custom-dialog-overlay {
		padding: 0px;
	}

	.custom-dialog {
		width: 100% !important;
		height: 100% !important;
		resize: none !important;
	}
}


code:
// CSS external libraries live under ../css/libs
@import "./libs/source-sans-pro.min.css";
@import "./libs/fontawesome.min.css";

// Less files
@import "./common.less";
@import "./custom-dialog.less";
@import "./bookmark-item.less";


code:
<!DOCTYPE html>

	<base href="$$.RootPath$$">
	<title>$$.Book.Title$$ - Shiori - Bookmarks Manager</title>

	<meta charset="UTF-8">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">

	<link rel="apple-touch-icon-precomposed" sizes="152x152" href="assets/res/apple-touch-icon-152x152.png">
	<link rel="apple-touch-icon-precomposed" sizes="144x144" href="assets/res/apple-touch-icon-144x144.png">
	<link rel="icon" type="image/png" href="assets/res/favicon-32x32.png" sizes="32x32">
	<link rel="icon" type="image/png" href="assets/res/favicon-16x16.png" sizes="16x16">
	<link rel="icon" type="image/x-icon" href="assets/res/favicon.ico">

	<link href="assets/css/style.css" rel="stylesheet">

Added {{localtime()}}

$$.Book.Title$$

				View Original
				$$if .Book.HasArchive$$
				View Archive
				$$end$$
				$$if .Book.HasEbook$$
				Download Ebook
				$$end$$

			$$html .Book.HTML$$


code:
package views

import "embed"

//go:embed assets/js/* assets/css/* assets/res/*
var Assets embed.FS

//go:embed *.html
var Templates embed.FS


code:
<!DOCTYPE html>

	<base href="$$.RootPath$$">
	<title>Shiori - Bookmarks Manager</title>

	<meta charset="UTF-8">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">

	<link rel="apple-touch-icon-precomposed" sizes="152x152" href="assets/res/apple-touch-icon-152x152.png">
	<link rel="apple-touch-icon-precomposed" sizes="144x144" href="assets/res/apple-touch-icon-144x144.png">
	<link rel="icon" type="image/png" href="assets/res/favicon-32x32.png" sizes="32x32">
	<link rel="icon" type="image/png" href="assets/res/favicon-16x16.png" sizes="16x16">
	<link rel="icon" type="image/x-icon" href="assets/res/favicon.ico">

	<link href="assets/css/style.css" rel="stylesheet">

		<keep-alive>
			<component :is="activePage" :active-account="activeAccount" :app-options="appOptions" @setting-changed="saveSetting"></component>
		</keep-alive>
		<custom-dialog v-bind="dialog" />


code:
<!DOCTYPE html>

	<base href="$$.RootPath$$">
	<title>Login - Shiori</title>

	<meta charset="UTF-8">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">

	<link rel="apple-touch-icon-precomposed" sizes="152x152" href="assets/res/apple-touch-icon-152x152.png">
	<link rel="apple-touch-icon-precomposed" sizes="144x144" href="assets/res/apple-touch-icon-144x144.png">
	<link rel="icon" type="image/png" href="assets/res/favicon-32x32.png" sizes="32x32">
	<link rel="icon" type="image/png" href="assets/res/favicon-16x16.png" sizes="16x16">
	<link rel="icon" type="image/x-icon" href="assets/res/favicon.ico">

	<link href="assets/css/style.css" rel="stylesheet">

{{error}}

			<form @submit.prevent="login">

						栞shiori

simple bookmark manager

					Username:
					<input id="username" type="text" name="username" placeholder="Username" tabindex="1" autofocus />
					Password:
					<input id="password" type="password" name="password" placeholder="Password" tabindex="2"
						@keyup.enter="login">
					<input type="checkbox" name="remember" v-model="remember"
							tabindex="3">Remember me

					Log In

			</form>

$$.Version$$


code:
// +build dev

package webserver

import (
	"net/http"
)

var assets = http.Dir("internal/view")

func init() {
	developmentMode = true
}


code:
//go:build !dev
// +build !dev

package webserver

import (
	"io/fs"

	"github.com/go-shiori/shiori/internal"
)

var assets fs.FS

func init() {
	var err error
	assets, err = fs.Sub(internal.Assets, "view")
	if err != nil {
		panic(err)
	}
}


code:
package webserver

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	fp "path/filepath"
	"strconv"

	"github.com/go-shiori/shiori/internal/core"
	"github.com/go-shiori/shiori/internal/model"
	"github.com/julienschmidt/httprouter"
)

// ApiInsertViaExtension is handler for POST /api/bookmarks/ext
func (h *Handler) ApiInsertViaExtension(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
	ctx := r.Context()

	// Make sure session still valid
	err := h.validateSession(r)
	checkError(err)

	// Decode request
	request := model.Bookmark{}
	err = json.NewDecoder(r.Body).Decode(&request)
	checkError(err)

	// Clean up bookmark URL
	request.URL, err = core.RemoveUTMParams(request.URL)
	if err != nil {
		panic(fmt.Errorf("failed to clean URL: %v", err))
	}

	// Check if bookmark already exists.
	book, exist, err := h.DB.GetBookmark(ctx, 0, request.URL)
	if err != nil {
		panic(fmt.Errorf("failed to get bookmark, URL: %v", err))
	}

	// If it already exists, we need to set ID and tags.
	if exist {
		book.HTML = request.HTML

		mapOldTags := map[string]model.Tag{}
		for _, oldTag := range book.Tags {
			mapOldTags[oldTag.Name] = oldTag
		}

		for _, newTag := range request.Tags {
			if _, tagExist := mapOldTags[newTag.Name]; !tagExist {
				book.Tags = append(book.Tags, newTag)
			}
		}
	} else if request.Title == "" {
		request.Title = request.URL
	}

	// Since we are using extension, the extension might send the HTML content
	// so no need to download it again here. However, if it's empty, it might be not HTML file
	// so we download it here.
	var contentType string
	var contentBuffer io.Reader

	if request.HTML == "" {
		contentBuffer, contentType, _ = core.DownloadBookmark(request.URL)
	} else {
		contentType = "text/html; charset=UTF-8"
		contentBuffer = bytes.NewBufferString(request.HTML)
	}

	// Save the bookmark with whatever we already have downloaded
	// since we need the ID in order to download the archive
	// Only when old bookmark is not exists.
	if !exist {
		books, err := h.DB.SaveBookmarks(ctx, true, request)
		if err != nil {
			log.Printf("error saving bookmark before downloading content: %s", err)
			return
		}
		book = books[0]
	}

	// At this point the web page already downloaded.
	// Time to process it.
	if contentBuffer != nil {
		book.CreateArchive = true
		request := core.ProcessRequest{
			DataDir:     h.DataDir,
			Bookmark:    book,
			Content:     contentBuffer,
			ContentType: contentType,
		}

		var isFatalErr bool
		book, isFatalErr, err = core.ProcessBookmark(request)

		if tmp, ok := contentBuffer.(io.ReadCloser); ok {
			tmp.Close()
		}

		// If we can't process or update the saved bookmark, just log it and continue on with the
		// request.
		if err != nil && isFatalErr {
			log.Printf("failed to process bookmark: %v", err)
		} else if _, err := h.DB.SaveBookmarks(ctx, false, book); err != nil {
			log.Printf("error saving bookmark after downloading content: %s", err)
		}
	}

	// Return the new bookmark
	w.Header().Set("Content-Type", "application/json")
	err = json.NewEncoder(w).Encode(&book)
	checkError(err)
}

// ApiDeleteViaExtension is handler for DELETE /api/bookmark/ext
func (h *Handler) ApiDeleteViaExtension(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
	ctx := r.Context()

	// Make sure session still valid
	err := h.validateSession(r)
	checkError(err)

	// Decode request
	request := model.Bookmark{}
	err = json.NewDecoder(r.Body).Decode(&request)
	checkError(err)

	// Check if bookmark already exists.
	book, exist, err := h.DB.GetBookmark(ctx, 0, request.URL)
	checkError(err)

	if exist {
		// Delete bookmarks
		err = h.DB.DeleteBookmarks(ctx, book.ID)
		checkError(err)

		// Delete thumbnail image and archives from local disk
		strID := strconv.Itoa(book.ID)
		imgPath := fp.Join(h.DataDir, "thumb", strID)
		archivePath := fp.Join(h.DataDir, "archive", strID)

		os.Remove(imgPath)
		os.Remove(archivePath)
	}

	fmt.Fprint(w, 1)
}


code:
package webserver

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"math"
	"net/http"
	"os"
	"path"
	fp "path/filepath"
	"strconv"
	"strings"

	"github.com/go-shiori/shiori/internal/core"
	"github.com/go-shiori/shiori/internal/database"
	"github.com/go-shiori/shiori/internal/model"
	"github.com/julienschmidt/httprouter"
	"golang.org/x/crypto/bcrypt"
)

func downloadBookmarkContent(book *model.Bookmark, dataDir string, request *http.Request, keepTitle, keepExcerpt bool) (*model.Bookmark, error) {
	content, contentType, err := core.DownloadBookmark(book.URL)
	if err != nil {
		return nil, fmt.Errorf("error downloading url: %s", err)
	}

	processRequest := core.ProcessRequest{
		DataDir:     dataDir,
		Bookmark:    *book,
		Content:     content,
		ContentType: contentType,
		KeepTitle:   keepTitle,
		KeepExcerpt: keepExcerpt,
	}

	result, isFatalErr, err := core.ProcessBookmark(processRequest)
	content.Close()

	if err != nil && isFatalErr {
		return nil, fmt.Errorf("failed to process: %v", err)
	}

	return &result, err
}

// ApiLogout is handler for POST /api/logout
func (h *Handler) ApiLogout(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
	// Get session ID
	sessionID := h.GetSessionID(r)
	if sessionID != "" {
		h.SessionCache.Delete(sessionID)
	}

	fmt.Fprint(w, 1)
}

// ApiGetBookmarks is handler for GET /api/bookmarks
func (h *Handler) ApiGetBookmarks(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
	ctx := r.Context()

	// Make sure session still valid
	err := h.validateSession(r)
	checkError(err)

	// Get URL queries
	keyword := r.URL.Query().Get("keyword")
	strPage := r.URL.Query().Get("page")
	strTags := r.URL.Query().Get("tags")
	strExcludedTags := r.URL.Query().Get("exclude")

	tags := strings.Split(strTags, ",")
	if len(tags) == 1 && tags[0] == "" {
		tags = []string{}
	}

	excludedTags := strings.Split(strExcludedTags, ",")
	if len(excludedTags) == 1 && excludedTags[0] == "" {
		excludedTags = []string{}
	}

	page, _ := strconv.Atoi(strPage)
	if page < 1 {
		page = 1
	}

	// Prepare filter for database
	searchOptions := database.GetBookmarksOptions{
		Tags:         tags,
		ExcludedTags: excludedTags,
		Keyword:      keyword,
		Limit:        30,
		Offset:       (page - 1) * 30,
		OrderMethod:  database.ByLastAdded,
	}

	// Calculate max page
	nBookmarks, err := h.DB.GetBookmarksCount(ctx, searchOptions)
	checkError(err)
	maxPage := int(math.Ceil(float64(nBookmarks) / 30))

	// Fetch all matching bookmarks
	bookmarks, err := h.DB.GetBookmarks(ctx, searchOptions)
	checkError(err)

	// Get image URL for each bookmark, and check if it has archive
	for i := range bookmarks {
		strID := strconv.Itoa(bookmarks[i].ID)
		imgPath := fp.Join(h.DataDir, "thumb", strID)
		archivePath := fp.Join(h.DataDir, "archive", strID)
		ebookPath := fp.Join(h.DataDir, "ebook", strID+".epub")

		if FileExists(imgPath) {
			bookmarks[i].ImageURL = path.Join(h.RootPath, "bookmark", strID, "thumb")
		}

		if FileExists(archivePath) {
			bookmarks[i].HasArchive = true
		}
		if FileExists(ebookPath) {
			bookmarks[i].HasEbook = true
		}
	}

	// Return JSON response
	resp := map[string]interface{}{
		"page":      page,
		"maxPage":   maxPage,
		"bookmarks": bookmarks,
	}

	w.Header().Set("Content-Type", "application/json")
	err = json.NewEncoder(w).Encode(&resp)
	checkError(err)
}

// ApiGetTags is handler for GET /api/tags
func (h *Handler) ApiGetTags(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
	ctx := r.Context()

	// Make sure session still valid
	err := h.validateSession(r)
	checkError(err)

	// Fetch all tags
	tags, err := h.DB.GetTags(ctx)
	checkError(err)

	w.Header().Set("Content-Type", "application/json")
	err = json.NewEncoder(w).Encode(&tags)
	checkError(err)
}

// ApiRenameTag is handler for PUT /api/tag
func (h *Handler) ApiRenameTag(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
	ctx := r.Context()

	// Make sure session still valid
	err := h.validateSession(r)
	checkError(err)

	// Decode request
	tag := model.Tag{}
	err = json.NewDecoder(r.Body).Decode(&tag)
	checkError(err)

	// Update name
	err = h.DB.RenameTag(ctx, tag.ID, tag.Name)
	checkError(err)

	fmt.Fprint(w, 1)
}

// Bookmark is the record for an URL.
type apiInsertBookmarkPayload struct {
	URL           string      `json:"url"`
	Title         string      `json:"title"`
	Excerpt       string      `json:"excerpt"`
	Tags          []model.Tag `json:"tags"`
	CreateArchive bool        `json:"create_archive"`
	CreateEbook   bool        `json:"create_ebook"`
	MakePublic    int         `json:"public"`
	Async         bool        `json:"async"`
}

// newApiInsertBookmarkPayload
// Returns the payload struct with its defaults
func newAPIInsertBookmarkPayload() *apiInsertBookmarkPayload {
	return &apiInsertBookmarkPayload{
		CreateArchive: false,
		CreateEbook:   false,
		Async:         true,
	}
}

// ApiInsertBookmark is handler for POST /api/bookmark
func (h *Handler) ApiInsertBookmark(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
	ctx := r.Context()

	// Make sure session still valid
	err := h.validateSession(r)
	checkError(err)

	// Decode request
	payload := newAPIInsertBookmarkPayload()
	err = json.NewDecoder(r.Body).Decode(&payload)
	checkError(err)

	book := &model.Bookmark{
		URL:           payload.URL,
		Title:         payload.Title,
		Excerpt:       payload.Excerpt,
		Tags:          payload.Tags,
		Public:        payload.MakePublic,
		CreateArchive: payload.CreateArchive,
		CreateEbook:   payload.CreateEbook,
	}

	// Clean up bookmark URL
	book.URL, err = core.RemoveUTMParams(book.URL)
	if err != nil {
		panic(fmt.Errorf("failed to clean URL: %v", err))
	}

	userHasDefinedTitle := book.Title != ""
	// Make sure bookmark's title not empty
	if book.Title == "" {
		book.Title = book.URL
	}

	// Save bookmark to database
	results, err := h.DB.SaveBookmarks(ctx, true, *book)
	if err != nil || len(results) == 0 {
		panic(fmt.Errorf("failed to save bookmark: %v", err))
	}

	book = &results[0]

	if payload.Async {
		go func() {
			bookmark, err := downloadBookmarkContent(book, h.DataDir, r, userHasDefinedTitle, book.Excerpt != "")
			if err != nil {
				log.Printf("error downloading boorkmark: %s", err)
				return
			}
			if _, err := h.DB.SaveBookmarks(context.Background(), false, *bookmark); err != nil {
				log.Printf("failed to save bookmark: %s", err)
			}
		}()
	} else {
		// Workaround. Download content after saving the bookmark so we have the proper database
		// id already set in the object regardless of the database engine.
		book, err = downloadBookmarkContent(book, h.DataDir, r, userHasDefinedTitle, book.Excerpt != "")
		if err != nil {
			log.Printf("error downloading boorkmark: %s", err)
		} else if _, err := h.DB.SaveBookmarks(ctx, false, *book); err != nil {
			log.Printf("failed to save bookmark: %s", err)
		}
	}

	// Return the new bookmark
	w.Header().Set("Content-Type", "application/json")
	err = json.NewEncoder(w).Encode(results[0])
	checkError(err)
}

// ApiDeleteBookmarks is handler for DELETE /api/bookmark
func (h *Handler) ApiDeleteBookmark(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
	ctx := r.Context()

	// Make sure session still valid
	err := h.validateSession(r)
	checkError(err)

	// Decode request
	ids := []int{}
	err = json.NewDecoder(r.Body).Decode(&ids)
	checkError(err)

	// Delete bookmarks
	err = h.DB.DeleteBookmarks(ctx, ids...)
	checkError(err)

	// Delete thumbnail image and archives from local disk
	for _, id := range ids {
		strID := strconv.Itoa(id)
		imgPath := fp.Join(h.DataDir, "thumb", strID)
		archivePath := fp.Join(h.DataDir, "archive", strID)
		ebookPath := fp.Join(h.DataDir, "ebook", strID+".epub")

		os.Remove(imgPath)
		os.Remove(archivePath)
		os.Remove(ebookPath)
	}

	fmt.Fprint(w, 1)
}

// ApiUpdateBookmark is handler for PUT /api/bookmarks
func (h *Handler) ApiUpdateBookmark(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
	ctx := r.Context()

	// Make sure session still valid
	err := h.validateSession(r)
	checkError(err)

	// Decode request
	request := model.Bookmark{}
	err = json.NewDecoder(r.Body).Decode(&request)
	checkError(err)

	// Validate input
	if request.Title == "" {
		panic(fmt.Errorf("title must not empty"))
	}

	// Get existing bookmark from database
	filter := database.GetBookmarksOptions{
		IDs:         []int{request.ID},
		WithContent: true,
	}

	bookmarks, err := h.DB.GetBookmarks(ctx, filter)
	checkError(err)
	if len(bookmarks) == 0 {
		panic(fmt.Errorf("no bookmark with matching ids"))
	}

	// Set new bookmark data
	book := bookmarks[0]
	book.URL = request.URL
	book.Title = request.Title
	book.Excerpt = request.Excerpt
	book.Public = request.Public

	// Clean up bookmark URL
	book.URL, err = core.RemoveUTMParams(book.URL)
	if err != nil {
		panic(fmt.Errorf("failed to clean URL: %v", err))
	}

	// Set new tags
	for i := range book.Tags {
		book.Tags[i].Deleted = true
	}

	for _, newTag := range request.Tags {
		for i, oldTag := range book.Tags {
			if newTag.Name == oldTag.Name {
				newTag.ID = oldTag.ID
				book.Tags[i].Deleted = false
				break
			}
		}

		if newTag.ID == 0 {
			book.Tags = append(book.Tags, newTag)
		}
	}

	// Update database
	res, err := h.DB.SaveBookmarks(ctx, false, book)
	checkError(err)

	// Add thumbnail image to the saved bookmarks again
	newBook := res[0]
	newBook.ImageURL = request.ImageURL
	newBook.HasArchive = request.HasArchive

	// Return new saved result
	w.Header().Set("Content-Type", "application/json")
	err = json.NewEncoder(w).Encode(&newBook)
	checkError(err)
}

// ApiUpdateBookmarkTags is handler for PUT /api/bookmarks/tags
func (h *Handler) ApiUpdateBookmarkTags(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
	ctx := r.Context()

	// Make sure session still valid
	err := h.validateSession(r)
	checkError(err)

	// Decode request
	request := struct {
		IDs  []int       `json:"ids"`
		Tags []model.Tag `json:"tags"`
	}{}

	err = json.NewDecoder(r.Body).Decode(&request)
	checkError(err)

	// Validate input
	if len(request.IDs) == 0 || len(request.Tags) == 0 {
		panic(fmt.Errorf("IDs and tags must not empty"))
	}

	// Get existing bookmark from database
	filter := database.GetBookmarksOptions{
		IDs:         request.IDs,
		WithContent: true,
	}

	bookmarks, err := h.DB.GetBookmarks(ctx, filter)
	checkError(err)
	if len(bookmarks) == 0 {
		panic(fmt.Errorf("no bookmark with matching ids"))
	}

	// Set new tags
	for i, book := range bookmarks {
		for _, newTag := range request.Tags {
			for _, oldTag := range book.Tags {
				if newTag.Name == oldTag.Name {
					newTag.ID = oldTag.ID
					break
				}
			}

			if newTag.ID == 0 {
				book.Tags = append(book.Tags, newTag)
			}
		}

		bookmarks[i] = book
	}

	// Update database
	bookmarks, err = h.DB.SaveBookmarks(ctx, false, bookmarks...)
	checkError(err)

	// Get image URL for each bookmark
	for i := range bookmarks {
		strID := strconv.Itoa(bookmarks[i].ID)
		imgPath := fp.Join(h.DataDir, "thumb", strID)
		imgURL := path.Join(h.RootPath, "bookmark", strID, "thumb")

		if FileExists(imgPath) {
			bookmarks[i].ImageURL = imgURL
		}
	}

	// Return new saved result
	err = json.NewEncoder(w).Encode(&bookmarks)
	checkError(err)
}

// ApiGetAccounts is handler for GET /api/accounts
func (h *Handler) ApiGetAccounts(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
	ctx := r.Context()

	// Make sure session still valid
	err := h.validateSession(r)
	checkError(err)

	// Get list of usernames from database
	accounts, err := h.DB.GetAccounts(ctx, database.GetAccountsOptions{})
	checkError(err)

	w.Header().Set("Content-Type", "application/json")
	err = json.NewEncoder(w).Encode(&accounts)
	checkError(err)
}

// ApiInsertAccount is handler for POST /api/accounts
func (h *Handler) ApiInsertAccount(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
	ctx := r.Context()

	// Make sure session still valid
	err := h.validateSession(r)
	checkError(err)

	// Decode request
	var account model.Account
	err = json.NewDecoder(r.Body).Decode(&account)
	checkError(err)

	// Save account to database
	err = h.DB.SaveAccount(ctx, account)
	checkError(err)

	fmt.Fprint(w, 1)
}

// ApiUpdateAccount is handler for PUT /api/accounts
func (h *Handler) ApiUpdateAccount(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
	ctx := r.Context()

	// Make sure session still valid
	err := h.validateSession(r)
	checkError(err)

	// Decode request
	request := struct {
		Username    string `json:"username"`
		OldPassword string `json:"oldPassword"`
		NewPassword string `json:"newPassword"`
		Owner       bool   `json:"owner"`
	}{}

	err = json.NewDecoder(r.Body).Decode(&request)
	checkError(err)

	// Get existing account data from database
	account, exist, err := h.DB.GetAccount(ctx, request.Username)
	checkError(err)

	if !exist {
		panic(fmt.Errorf("username doesn't exist"))
	}

	// Compare old password with database
	err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(request.OldPassword))
	if err != nil {
		panic(fmt.Errorf("old password doesn't match"))
	}

	// Save new password to database
	account.Password = request.NewPassword
	account.Owner = request.Owner
	err = h.DB.SaveAccount(ctx, account)
	checkError(err)

	// Delete user's sessions
	if val, found := h.UserCache.Get(request.Username); found {
		userSessions := val.([]string)
		for _, session := range userSessions {
			h.SessionCache.Delete(session)
		}

		h.UserCache.Delete(request.Username)
	}

	fmt.Fprint(w, 1)
}

// ApiDeleteAccount is handler for DELETE /api/accounts
func (h *Handler) ApiDeleteAccount(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
	ctx := r.Context()

	// Make sure session still valid
	err := h.validateSession(r)
	checkError(err)

	// Decode request
	usernames := []string{}
	err = json.NewDecoder(r.Body).Decode(&usernames)
	checkError(err)

	// Delete accounts
	err = h.DB.DeleteAccounts(ctx, usernames...)
	checkError(err)

	// Delete user's sessions
	var userSessions []string
	for _, username := range usernames {
		if val, found := h.UserCache.Get(username); found {
			userSessions = val.([]string)
			for _, session := range userSessions {
				h.SessionCache.Delete(session)
			}

			h.UserCache.Delete(username)
		}
	}

	fmt.Fprint(w, 1)
}


code:
package webserver

import (
	"bytes"
	"compress/gzip"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"path"
	fp "path/filepath"
	"strconv"
	"strings"

	"github.com/PuerkitoBio/goquery"
	"github.com/go-shiori/warc"
	"github.com/julienschmidt/httprouter"

	"github.com/go-shiori/shiori/internal/model"
)

// ServeBookmarkContent is handler for GET /bookmark/:id/content
func (h *Handler) ServeBookmarkContent(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
	ctx := r.Context()

	// Get bookmark ID from URL
	strID := ps.ByName("id")
	id, err := strconv.Atoi(strID)
	checkError(err)

	// Get bookmark in database
	bookmark, exist, err := h.DB.GetBookmark(ctx, id, "")
	checkError(err)

	if !exist {
		panic(fmt.Errorf("bookmark not found"))
	}

	// If it's not public, make sure session still valid
	if bookmark.Public != 1 {
		err = h.validateSession(r)
		if err != nil {
			newPath := path.Join(h.RootPath, "/login")
			redirectURL := createRedirectURL(newPath, r.URL.String())
			redirectPage(w, r, redirectURL)
			return
		}
	}

	// Check if it has ebook.
	ebookPath := fp.Join(h.DataDir, "ebook", strID+".epub")
	if FileExists(ebookPath) {
		bookmark.HasEbook = true
	}
	archivePath := fp.Join(h.DataDir, "archive", strID)
	if FileExists(archivePath) {
		bookmark.HasArchive = true

		// Open archive, look in cache first
		var archive *warc.Archive
		cacheData, found := h.ArchiveCache.Get(strID)

		if found {
			archive = cacheData.(*warc.Archive)
		} else {
			archivePath := fp.Join(h.DataDir, "archive", strID)
			archive, err = warc.Open(archivePath)
			checkError(err)

			h.ArchiveCache.Set(strID, archive, 0)
		}

		// Find all image and convert its source to use the archive URL.
		createArchivalURL := func(archivalName string) string {
			archivalURL := *r.URL
			archivalURL.Path = path.Join(h.RootPath, "bookmark", strID, "archive", archivalName)
			return archivalURL.String()
		}

		buffer := strings.NewReader(bookmark.HTML)
		doc, err := goquery.NewDocumentFromReader(buffer)
		checkError(err)

		doc.Find("img, picture, figure, source").Each(func(_ int, node *goquery.Selection) {
			// Get the needed attributes
			src, _ := node.Attr("src")
			strSrcSets, _ := node.Attr("srcset")

			// Convert `src` attributes
			if src != "" {
				archivalName := getArchivalName(src)
				if archivalName != "" && archive.HasResource(archivalName) {
					node.SetAttr("src", createArchivalURL(archivalName))
				}
			}

			// Split srcset by comma, then process it like any URLs
			srcSets := strings.Split(strSrcSets, ",")
			for i, srcSet := range srcSets {
				srcSet = strings.TrimSpace(srcSet)
				parts := strings.SplitN(srcSet, " ", 2)
				if parts[0] == "" {
					continue
				}

				archivalName := getArchivalName(parts[0])
				if archivalName != "" && archive.HasResource(archivalName) {
					archivalURL := createArchivalURL(archivalName)
					srcSets[i] = strings.Replace(srcSets[i], parts[0], archivalURL, 1)
				}
			}

			if len(srcSets) > 0 {
				node.SetAttr("srcset", strings.Join(srcSets, ","))
			}
		})

		bookmark.HTML, err = goquery.OuterHtml(doc.Selection)
		checkError(err)
	}

	// Execute template
	if developmentMode {
		if err := h.PrepareTemplates(); err != nil {
			log.Printf("error during template preparation: %s", err)
		}
	}

	tplData := struct {
		RootPath string
		Book     model.Bookmark
	}{h.RootPath, bookmark}

	err = h.templates["content"].Execute(w, &tplData)
	checkError(err)
}

// ServeThumbnailImage is handler for GET /bookmark/:id/thumb
func (h *Handler) ServeThumbnailImage(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
	// Get bookmark ID from URL
	ctx := r.Context()

	// Get bookmark ID from URL
	strID := ps.ByName("id")

	// Get bookmark from database
	id, err := strconv.Atoi(strID)
	checkError(err)
	bookmark, exist, err := h.DB.GetBookmark(ctx, id, "")
	checkError(err)

	if !exist {
		log.Println("error: bookmark not found")
		return
	}

	// If it's not public, make sure session still valid
	if bookmark.Public != 1 {
		err = h.validateSession(r)
		if err != nil {
			newPath := path.Join(h.RootPath, "/login")
			redirectURL := createRedirectURL(newPath, r.URL.String())
			redirectPage(w, r, redirectURL)
			return
		}
	}
	// Open image
	imgPath := fp.Join(h.DataDir, "thumb", strID)
	img, err := os.Open(imgPath)
	checkError(err)
	defer img.Close()

	// Get image type from its 512 first bytes
	buffer := make([]byte, 512)
	_, err = img.Read(buffer)
	checkError(err)

	mimeType := http.DetectContentType(buffer)
	w.Header().Set("Content-Type", mimeType)

	// Set cache value
	info, err := img.Stat()
	checkError(err)

	etag := fmt.Sprintf(`W/"%x-%x"`, info.ModTime().Unix(), info.Size())
	w.Header().Set("ETag", etag)
	w.Header().Set("Cache-Control", "max-age=86400")

	// Serve image
	if _, err := img.Seek(0, 0); err != nil {
		log.Printf("error during image seek: %s", err)
	}
	_, err = io.Copy(w, img)
	checkError(err)
}

// ServeBookmarkArchive is handler for GET /bookmark/:id/archive/*filepath
func (h *Handler) ServeBookmarkArchive(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
	ctx := r.Context()

	// Get parameter from URL
	strID := ps.ByName("id")
	resourcePath := ps.ByName("filepath")
	resourcePath = strings.TrimPrefix(resourcePath, "/")

	// Get bookmark from database
	id, err := strconv.Atoi(strID)
	checkError(err)

	bookmark, exist, err := h.DB.GetBookmark(ctx, id, "")
	checkError(err)

	if !exist {
		panic(fmt.Errorf("bookmark not found"))
	}

	// If it's not public, make sure session still valid
	if bookmark.Public != 1 {
		err = h.validateSession(r)
		if err != nil {
			newPath := path.Join(h.RootPath, "/login")
			redirectURL := createRedirectURL(newPath, r.URL.String())
			redirectPage(w, r, redirectURL)
			return
		}
	}

	// Open archive, look in cache first
	var archive *warc.Archive
	cacheData, found := h.ArchiveCache.Get(strID)

	if found {
		archive = cacheData.(*warc.Archive)
	} else {
		archivePath := fp.Join(h.DataDir, "archive", strID)
		archive, err = warc.Open(archivePath)
		checkError(err)

		h.ArchiveCache.Set(strID, archive, 0)
	}

	content, contentType, err := archive.Read(resourcePath)
	checkError(err)

	// Set response header
	w.Header().Set("Content-Encoding", "gzip")
	w.Header().Set("Content-Type", contentType)

	// If this is HTML and root, inject shiori header
	if strings.Contains(strings.ToLower(contentType), "text/html") && resourcePath == "" {
		// Extract gzip
		buffer := bytes.NewBuffer(content)
		gzipReader, err := gzip.NewReader(buffer)
		checkError(err)

		// Parse gzipped content
		doc, err := goquery.NewDocumentFromReader(gzipReader)
		checkError(err)

		// Add Shiori overlay
		tplOutput := bytes.NewBuffer(nil)
		err = h.templates["archive"].Execute(tplOutput, &bookmark)
		checkError(err)

		archiveCSSPath := path.Join(h.RootPath, "/assets/css/archive.css")

		docHead := doc.Find("head")
		docHead.PrependHtml(`<meta charset="UTF-8">`)
		docHead.AppendHtml(`<link href="` + archiveCSSPath + `" rel="stylesheet">`)
		doc.Find("body").PrependHtml(tplOutput.String())
		doc.Find("body").AddClass("shiori-archive-content")

		// Revert back to HTML
		outerHTML, err := goquery.OuterHtml(doc.Selection)
		checkError(err)

		// Gzip it again and send to response writer
		gzipWriter := gzip.NewWriter(w)
		if _, err := gzipWriter.Write([]byte(outerHTML)); err != nil {
			log.Printf("error writing gzip file: %s", err)
		}
		gzipWriter.Flush()
		return
	}

	// Serve content
	if _, err := w.Write(content); err != nil {
		log.Printf("error writing response: %s", err)
	}
}

// serveEbook is handler for GET /bookmark/:id/ebook
func (h *Handler) ServeBookmarkEbook(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
	ctx := r.Context()

	// Get bookmark ID from URL
	strID := ps.ByName("id")
	id, err := strconv.Atoi(strID)
	checkError(err)
	// Get bookmark in database
	bookmark, exist, err := h.DB.GetBookmark(ctx, id, "")
	checkError(err)

	if !exist {
		http.Error(w, "bookmark not found", http.StatusNotFound)
		return
	}

	// If it's not public, make sure session still valid
	if bookmark.Public != 1 {
		err = h.validateSession(r)
		if err != nil {
			newPath := path.Join(h.RootPath, "/login")
			redirectURL := createRedirectURL(newPath, r.URL.String())
			redirectPage(w, r, redirectURL)
			return
		}
	}

	// Check if it has ebook.
	ebookPath := fp.Join(h.DataDir, "ebook", strID+".epub")
	if !FileExists(ebookPath) {
		http.Error(w, "ebook not found", http.StatusNotFound)
		return
	}

	epub, err := os.Open(ebookPath)
	if err != nil {
		http.Error(w, "Internal server error", http.StatusNotFound)
		return
	}
	defer epub.Close()
	// Set content type
	w.Header().Set("Content-Type", "application/epub+zip")
	// Set cache value
	info, err := epub.Stat()
	if err != nil {
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	etag := fmt.Sprintf(`W/"%x-%x"`, info.ModTime().Unix(), info.Size())
	w.Header().Set("ETag", etag)
	w.Header().Set("Cache-Control", "max-age=86400")

	// Serve epub file
	if _, err := epub.Seek(0, 0); err != nil {
		log.Printf("error during epub seek: %s", err)
	}
	_, err = io.Copy(w, epub)
	if err != nil {
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
}


code:
package webserver

import (
	"fmt"
	"html/template"
	"net/http"
	"strings"

	"github.com/go-shiori/shiori/internal/config"
	"github.com/go-shiori/shiori/internal/database"
	"github.com/go-shiori/shiori/internal/model"
	cch "github.com/patrickmn/go-cache"
	"github.com/sirupsen/logrus"
)

var developmentMode = false

// Handler is Handler for serving the web interface.
type Handler struct {
	DB           database.DB
	DataDir      string
	RootPath     string
	UserCache    *cch.Cache
	SessionCache *cch.Cache
	ArchiveCache *cch.Cache
	Log          bool

	dependencies *config.Dependencies

	templates map[string]*template.Template
}

func (h *Handler) PrepareSessionCache() {
	h.SessionCache.OnEvicted(func(key string, val interface{}) {
		account := val.(model.Account)
		arr, found := h.UserCache.Get(account.Username)
		if !found {
			return
		}

		sessionIDs := arr.([]string)
		for i := 0; i < len(sessionIDs); i++ {
			if sessionIDs[i] == key {
				sessionIDs = append(sessionIDs[:i], sessionIDs[i+1:]...)
				break
			}
		}

		h.UserCache.Set(account.Username, sessionIDs, -1)
	})
}

func (h *Handler) PrepareTemplates() error {
	// Prepare variables
	var err error
	h.templates = make(map[string]*template.Template)

	// Prepare func map
	funcMap := template.FuncMap{
		"html": func(s string) template.HTML {
			return template.HTML(s)
		},
	}

	// Create template for login, index and content
	for _, name := range []string{"login", "index", "content"} {
		h.templates[name], err = createTemplate(name+".html", funcMap)
		if err != nil {
			return err
		}
	}

	// Create template for archive overlay
	h.templates["archive"], err = template.New("archive").Delims("$$", "$$").Parse(
		`

栞shiori

		View Original
		$$if .HasContent$$
		View Readable
		$$end$$

`)
	if err != nil {
		return err
	}

	return nil
}

func (h *Handler) GetSessionID(r *http.Request) string {
	// Try to get session ID from the header
	sessionID := r.Header.Get("X-Session-Id")

	// If not, try it from the cookie
	if sessionID == "" {
		cookie, err := r.Cookie("session-id")
		if err != nil {
			return ""
		}

		sessionID = cookie.Value
	}

	return sessionID
}

// validateSession checks whether user session is still valid or not
func (h *Handler) validateSession(r *http.Request) error {
	authorization := r.Header.Get(model.AuthorizationHeader)
	if authorization != "" {
		authParts := strings.SplitN(authorization, " ", 2)
		if len(authParts) != 2 && authParts[0] != model.AuthorizationTokenType {
			return fmt.Errorf("session has been expired")
		}

		account, err := h.dependencies.Domains.Auth.CheckToken(r.Context(), authParts[1])
		if err != nil {
			return fmt.Errorf("session has been expired")
		}

		if r.Method != "" && r.Method != "GET" && !account.Owner {
			return fmt.Errorf("account level is not sufficient")
		}

		h.dependencies.Log.WithFields(logrus.Fields{
			"username": account.Username,
			"method":   r.Method,
			"path":     r.URL.Path,
		}).Info("allowing legacy api access using JWT token")

		return nil
	}

	sessionID := h.GetSessionID(r)
	if sessionID == "" {
		return fmt.Errorf("session is not exist")
	}

	// Make sure session is not expired yet
	val, found := h.SessionCache.Get(sessionID)
	if !found {
		return fmt.Errorf("session has been expired")
	}

	// If this is not get request, make sure it's owner
	if r.Method != "" && r.Method != "GET" {
		if account := val.(model.Account); !account.Owner {
			return fmt.Errorf("account level is not sufficient")
		}
	}

	return nil
}


code:
package webserver

import (
	"time"

	"github.com/go-shiori/shiori/internal/config"
	"github.com/go-shiori/shiori/internal/database"
	cch "github.com/patrickmn/go-cache"
)

// Config is parameter that used for starting web server
type Config struct {
	DB            database.DB
	DataDir       string
	ServerAddress string
	ServerPort    int
	RootPath      string
	Log           bool
}

func GetLegacyHandler(cfg Config, dependencies *config.Dependencies) *Handler {
	return &Handler{
		DB:           cfg.DB,
		DataDir:      cfg.DataDir,
		UserCache:    cch.New(time.Hour, 10*time.Minute),
		SessionCache: cch.New(time.Hour, 10*time.Minute),
		ArchiveCache: cch.New(time.Minute, 5*time.Minute),
		RootPath:     cfg.RootPath,
		Log:          cfg.Log,
		dependencies: dependencies,
	}
}


code:
package webserver

import (
	"html/template"
	"io"
	"net"
	"net/http"
	nurl "net/url"
	"os"
	"regexp"
	"strings"
	"syscall"
)

var rxRepeatedStrip = regexp.MustCompile(`(?i)-+`)

func createRedirectURL(newPath, previousPath string) string {
	urlQueries := nurl.Values{}
	urlQueries.Set("dst", previousPath)

	redirectURL, _ := nurl.Parse(newPath)
	redirectURL.RawQuery = urlQueries.Encode()
	return redirectURL.String()
}

func redirectPage(w http.ResponseWriter, r *http.Request, url string) {
	w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
	w.Header().Set("Pragma", "no-cache")
	w.Header().Set("Expires", "0")
	http.Redirect(w, r, url, http.StatusMovedPermanently)
}

func FileExists(filePath string) bool {
	info, err := os.Stat(filePath)
	return err == nil && !info.IsDir()
}

func createTemplate(filename string, funcMap template.FuncMap) (*template.Template, error) {
	// Open file
	src, err := assets.Open(filename)
	if err != nil {
		return nil, err
	}
	defer src.Close()

	// Read file content
	srcContent, err := io.ReadAll(src)
	if err != nil {
		return nil, err
	}

	// Create template
	return template.New(filename).Delims("$$", "$$").Funcs(funcMap).Parse(string(srcContent))
}

// getArchivalName converts an URL into an archival name.
func getArchivalName(src string) string {
	archivalURL := src

	// Some URL have its query or path escaped, e.g. Wikipedia and Dev.to.
	// For example, Wikipedia's stylesheet looks like this :
	//   load.php?lang=en&modules=ext.3d.styles%7Cext.cite.styles%7Cext.uls.interlanguage
	// However, when browser download it, it will be registered as unescaped query :
	//   load.php?lang=en&modules=ext.3d.styles|ext.cite.styles|ext.uls.interlanguage
	// So, for archival URL, we need to unescape the query and path first.
	tmp, err := nurl.Parse(src)
	if err == nil {
		unescapedQuery, _ := nurl.QueryUnescape(tmp.RawQuery)
		if unescapedQuery != "" {
			tmp.RawQuery = unescapedQuery
		}

		archivalURL = tmp.String()
		archivalURL = strings.Replace(archivalURL, tmp.EscapedPath(), tmp.Path, 1)
	}

	archivalURL = strings.ReplaceAll(archivalURL, "://", "/")
	archivalURL = strings.ReplaceAll(archivalURL, "?", "-")
	archivalURL = strings.ReplaceAll(archivalURL, "#", "-")
	archivalURL = strings.ReplaceAll(archivalURL, "/", "-")
	archivalURL = strings.ReplaceAll(archivalURL, " ", "-")
	archivalURL = rxRepeatedStrip.ReplaceAllString(archivalURL, "-")

	return archivalURL
}

func checkError(err error) {
	if err == nil {
		return
	}

	// Check for a broken connection, as it is not really a
	// condition that warrants a panic stack trace.
	if ne, ok := err.(*net.OpError); ok {
		if se, ok := ne.Err.(*os.SyscallError); ok {
			if se.Err == syscall.EPIPE || se.Err == syscall.ECONNRESET {
				return
			}
		}
	}

	panic(err)
}


code:
package webserver

import (
	"fmt"
	"math/rand"
	"net"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestParseCidr(t *testing.T) {
	res := parseCIDR("192.168.0.0/16", "internal 192.168.x.x")
	assert.Equal(t, res.IP, net.IP([]byte{192, 168, 0, 0}))
	assert.Equal(t, res.Mask, net.IPMask([]byte{255, 255, 0, 0}))
}

func TestParseCidrInvalidAddr(t *testing.T) {
	assert.Panics(t, func() { parseCIDR("192.168.0.0/34", "internal 192.168.x.x") })
}

func TestIsPrivateIP(t *testing.T) {
	assert.True(t, IsPrivateIP(net.ParseIP("127.0.0.1")), "should be private")
	assert.True(t, IsPrivateIP(net.ParseIP("192.168.254.254")), "should be private")
	assert.True(t, IsPrivateIP(net.ParseIP("10.255.0.3")), "should be private")
	assert.True(t, IsPrivateIP(net.ParseIP("172.16.255.255")), "should be private")
	assert.True(t, IsPrivateIP(net.ParseIP("172.31.255.255")), "should be private")
	assert.True(t, !IsPrivateIP(net.ParseIP("128.0.0.1")), "should be private")
	assert.True(t, !IsPrivateIP(net.ParseIP("192.169.255.255")), "should not be private")
	assert.True(t, !IsPrivateIP(net.ParseIP("9.255.0.255")), "should not be private")
	assert.True(t, !IsPrivateIP(net.ParseIP("172.32.255.255")), "should not be private")

	assert.True(t, IsPrivateIP(net.ParseIP("::0")), "should be private")
	assert.True(t, IsPrivateIP(net.ParseIP("::1")), "should be private")
	assert.True(t, !IsPrivateIP(net.ParseIP("::2")), "should not be private")

	assert.True(t, IsPrivateIP(net.ParseIP("fe80::1")), "should be private")
	assert.True(t, IsPrivateIP(net.ParseIP("febf::1")), "should be private")
	assert.True(t, !IsPrivateIP(net.ParseIP("fec0::1")), "should not be private")
	assert.True(t, !IsPrivateIP(net.ParseIP("feff::1")), "should not be private")

	assert.True(t, IsPrivateIP(net.ParseIP("ff00::1")), "should be private")
	assert.True(t, IsPrivateIP(net.ParseIP("ff10::1")), "should be private")
	assert.True(t, IsPrivateIP(net.ParseIP("ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff")), "should be private")

	assert.True(t, IsPrivateIP(net.ParseIP("2002::")), "should be private")
	assert.True(t, IsPrivateIP(net.ParseIP("2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff")), "should be private")
	assert.True(t, IsPrivateIP(net.ParseIP("0100::")), "should be private")
	assert.True(t, IsPrivateIP(net.ParseIP("0100::0000:ffff:ffff:ffff:ffff")), "should be private")
	assert.True(t, !IsPrivateIP(net.ParseIP("0100::0001:0000:0000:0000:0000")), "should be private")
}

func TestIsIpValidAndPublic(t *testing.T) {
	// test empty address
	assert.False(t, IsIPValidAndPublic(""))
	// test public address
	assert.True(t, IsIPValidAndPublic("31.41.244.124"))
	assert.True(t, IsIPValidAndPublic("62.233.50.248"))
	// trim head or tail space
	assert.True(t, IsIPValidAndPublic(" 62.233.50.249"))
	assert.True(t, IsIPValidAndPublic(" 62.233.50.250 "))
	assert.True(t, IsIPValidAndPublic("62.233.50.251 "))
	// test private address
	assert.False(t, IsIPValidAndPublic("10.1.123.52"))
	assert.False(t, IsIPValidAndPublic("192.168.123.24"))
	assert.False(t, IsIPValidAndPublic("172.17.0.1"))
}

func BenchmarkIsPrivateIPv4(b *testing.B) {
	// range: 2-254
	n1 := 2 + rand.Intn(252)
	n2 := 2 + rand.Intn(252)
	for i := 0; i < b.N; i++ {
		IsPrivateIP(net.ParseIP(fmt.Sprintf("192.168.%d.%d", n1, n2)))
	}
}

func BenchmarkIsPrivateIPv6(b *testing.B) {
	n1 := 2 + rand.Intn(252)
	for i := 0; i < b.N; i++ {
		IsPrivateIP(net.ParseIP(fmt.Sprintf("2002::%d", n1)))
	}
}

func testIsPublicHttpRequestAddressHelper(
	t *testing.T, wantIP string, headers map[string]string, isPublic bool,
) {
	testIsPublicHttpRequestAddressHelperWrapped(t, nil, wantIP, headers, isPublic)
}

func testIsPublicHttpRequestAddressHelperWrapped(
	t *testing.T, r *http.Request, wantIP string, headers map[string]string, isPublic bool,
) {
	var (
		err    error
		userIP string
	)
	if r == nil {
		r = httptest.NewRequest("GET", "/", nil)
	}
	for k, v := range headers {
		r.Header.Set(k, v)
	}

	origVal := GetUserRealIP(r)
	if strings.Contains(origVal, ":") {
		userIP, _, err = net.SplitHostPort(origVal)
		if err != nil {
			t.Error(err)
		}
	} else {
		userIP = origVal
	}

	if isPublic {
		// should equal first ip in list
		assert.Equal(t, wantIP, userIP)
		assert.True(t, IsIPValidAndPublic(userIP))
	} else {
		assert.Equal(t, origVal, r.RemoteAddr)
		assert.False(t, IsIPValidAndPublic(userIP))
	}
}

func TestGetUserRealIPWithSetRemoteAddr(t *testing.T) {
	// Test Public RemoteAddr
	testIsPublicHttpRequestAddressHelper(t, "", nil, false)

	r := httptest.NewRequest("GET", "/", nil)
	wantIP := "34.23.123.122"
	r.RemoteAddr = fmt.Sprintf("%s:1234", wantIP)
	testIsPublicHttpRequestAddressHelperWrapped(t, r, wantIP, nil, true)
}

func TestGetUserRealIPWithInvalidRemoteAddr(t *testing.T) {
	// Test Public RemoteAddr
	testIsPublicHttpRequestAddressHelper(t, "", nil, false)

	r := httptest.NewRequest("GET", "/", nil)
	wantIP := "34.23.123.122"
	// without port
	r.RemoteAddr = wantIP
	testIsPublicHttpRequestAddressHelperWrapped(t, r, wantIP, nil, true)
}

func TestGetUserRealIPWithEmptyHeader(t *testing.T) {
	// Test Empty X-Real-IP
	testIsPublicHttpRequestAddressHelper(t, "", nil, false)
}

func TestGetUserRealIPWithInvalidHeaderValue(t *testing.T) {
	for _, name := range userRealIpHeaderCandidates {
		// invalid ip
		m := map[string]string{
			name: "31.41.24a.12",
		}
		testIsPublicHttpRequestAddressHelper(t, "", m, false)
	}
}

func TestGetUserRealIPWithXRealIpHeader(t *testing.T) {
	// Test public Real IP
	for _, name := range userRealIpHeaderCandidates {
		wantIP := "31.41.242.12"
		m := map[string]string{
			name: wantIP,
		}
		testIsPublicHttpRequestAddressHelper(t, wantIP, m, true)
	}
}

func TestGetUserRealIPWithPrivateXRealIpHeader(t *testing.T) {
	for _, name := range userRealIpHeaderCandidates {
		wantIP := "192.168.123.123"
		// test private ip in header
		m := map[string]string{
			name: wantIP,
		}
		testIsPublicHttpRequestAddressHelper(t, wantIP, m, false)
	}
}

func TestGetUserRealIPWithXRealIpListHeader(t *testing.T) {
	// Test Real IP List
	for _, name := range userRealIpHeaderCandidates {
		ipList := []string{"34.23.123.122", "34.23.123.123"}
		// should equal first ip in list
		wantIP := ipList[0]
		// test private ip in header
		m := map[string]string{
			name: strings.Join(ipList, ", "),
		}
		testIsPublicHttpRequestAddressHelper(t, wantIP, m, true)
	}
}

func TestGetUserRealIPWithXRealIpHeaderIgnoreComma(t *testing.T) {
	// Test Real IP List with leading or tailing comma
	wantIP := "34.23.123.124"
	ipVariants := []string{
		",34.23.123.124", " ,34.23.123.124", "\t,34.23.123.124",
		",34.23.123.124,", " ,34.23.123.124, ", "\t,34.23.123.124,\t",
		"34.23.123.124,", "34.23.123.124, ", "34.23.123.124,\t"}
	for _, variant := range ipVariants {
		for _, name := range userRealIpHeaderCandidates {
			m := map[string]string{name: variant}
			testIsPublicHttpRequestAddressHelper(t, wantIP, m, true)
		}
	}
}

func TestGetUserRealIPWithDifferentHeaderOrder(t *testing.T) {
	var m map[string]string
	wantIP := "34.23.123.124"
	m = map[string]string{
		"X-Real-Ip":       "192.168.123.122",
		"X-Forwarded-For": wantIP,
	}

	testIsPublicHttpRequestAddressHelper(t, wantIP, m, true)
	m = map[string]string{
		"X-Real-Ip":       wantIP,
		"X-Forwarded-For": "192.168.123.122",
	}
	testIsPublicHttpRequestAddressHelper(t, wantIP, m, true)
}


code:
//go:generate go run assets-generator.go

package main

import (
	"github.com/go-shiori/shiori/internal/cmd"
	"github.com/go-shiori/shiori/internal/model"
	"github.com/sirupsen/logrus"

	// Database driver
	_ "github.com/go-sql-driver/mysql"
	_ "github.com/lib/pq"
	_ "modernc.org/sqlite"

	// Add this to prevent it removed by go mod tidy
	_ "github.com/shurcooL/vfsgen"
)

var (
	version = "dev"
	commit  = "none"
	date    = "unknown"
)

func init() {
	// Set globally
	model.BuildVersion = version
	model.BuildCommit = commit
	model.BuildDate = date
}

func main() {
	err := cmd.ShioriCmd().Execute()
	if err != nil {
		logrus.Fatalln(err)
	}
}


code:
#!/bin/bash

INPUT_STYLECSS=internal/view/assets/less/style.less
OUTPUT_STYLECSS=internal/view/assets/css/style.css

INPUT_ARCHIVECSS=internal/view/assets/less/archive.less
OUTPUT_ARCHIVECSS=internal/view/assets/css/archive.css

# Use bun is installled
if [ -x "$(command -v bun)" ]; then
    bun install
    bun x prettier internal/view/ --write
    bun x lessc $INPUT_STYLECSS $OUTPUT_STYLECSS
    bun x lessc $INPUT_ARCHIVECSS $OUTPUT_ARCHIVECSS
    bun x clean-css-cli $CLEANCSS_OPTS -o $OUTPUT_STYLECSS $OUTPUT_STYLECSS
    bun x clean-css-cli $CLEANCSS_OPTS -o $OUTPUT_ARCHIVECSS $OUTPUT_ARCHIVECSS
    exit 0
fi

# Default to lessc and cleancss
lessc $INPUT_STYLECSS $OUTPUT_STYLECSS
lessc $INPUT_ARCHIVECSS $OUTPUT_ARCHIVECSS
cleancss $CLEANCSS_OPTS -o $OUTPUT_STYLECSS $OUTPUT_STYLECSS
cleancss $CLEANCSS_OPTS -o $OUTPUT_ARCHIVECSS $OUTPUT_ARCHIVECSS


code:
#!/bin/bash

# This script is used to check if the style.css file is up to date.

# Check if the git tree for CWD is clean
if [ -n "$(git status internal/view/assets/css --porcelain)" ]; then
    echo "❌ git tree is not clean. Please commit all changes before running this script."
    exit 1
fi

# Check if prettier is ok
if ! bun x prettier internal/view/ --check; then
    echo "❌ code style issues found. Please run 'make styles' and commit the changes."
    exit 1
fi

# Check style.css file
CLEANCSS_OPTS=${CLEANCSS_OPTS} make styles
if [ -n "$(git status internal/view/assets/css --porcelain)" ]; then
    echo "❌ style.css wasn't built from less changes. Please run 'make styles' and commit the changes."
    git checkout -- internal/view/assets/css/
    exit 1
fi


code:
#!/bin/bash

# This script is used to generate the swagger files for the API.
swag init --output=$SWAGGER_DOCS_PATH


code:
#!/bin/bash

# This script is used to check if the swagger files are up to date.

# Check if swag version is correct
CURRENT_SWAG_VERSION=$(swag --version | cut -d " " -f 3)
if [ "$CURRENT_SWAG_VERSION" != "$REQUIRED_SWAG_VERSION" ]; then
    echo "swag version is incorrect. Required version: $REQUIRED_SWAG_VERSION, current version: $CURRENT_SWAG_VERSION"
    exit 1
fi

# Check if the git tree for CWD is clean
if [ -n "$(git status docs/swagger --porcelain)" ]; then
    echo "❌ git tree is not clean. Please commit all changes before running this script."
    git diff
    exit 1
fi

# Check swag comments
make swag-fmt
if [ -n "$(git status docs/swagger --porcelain)" ]; then
    echo "❌ swag comments are not formatted. Please run 'make swag-fmt' and commit the changes."
    git diff
    git checkout -- docs/swagger
    exit 1
fi

# Check swagger documentation
make swagger
if [ -n "$(git status docs/swagger --porcelain)" ]; then
    echo "❌ swagger documentation not updated, please run 'make swagger' and commit the changes."
    git diff
    git checkout -- docs/swagger
    exit 1
fi


code:
#!/bin/bash

# Check if gotestfmt is installed
GOTESTFMT=$(which gotestfmt)
if [[ -z "${GOTESTFMT}" ]]; then
    echo "gotestfmt not found. Using test standard output."
fi

# if gotestfmt is installed, run with it
if [[ -n "${GOTESTFMT}" ]]; then
    set -o pipefail
    go test ./... ${GO_TEST_FLAGS} -json | gotestfmt ${GOTESTFMT_FLAGS}
else
    go test ./... ${GO_TEST_FLAGS}
fi


code:
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
  "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg width="32" height="32">
  <circle cx="16" cy="16" r="16"/>
</svg>


code:
.idea
d
gen
src/graphics/opengl/shaders/gen
/netrunner
*.iml
tags
src/networking/URI

*.user
NetRunner2008.ncb
*.suo
*.id*
*.nam
*.til
*.dbg
/glew32.dll
/libgcc_s_sjlj-1.dll
/libssp-0.dll
/libstdc++-6.dll
/netrunner.exe
.vscode
*.res*
*.aps
/glfw3.dll
/libfreetype-6.dll
/libharfbuzz-0.dll
/libwinpthread-1.dll
/out.txt
ntr-test-cfg.exe
*.o

repo_name: despair/netrunner

path: .gitignore


code:
UNAME := $(shell uname)
CXX         = gpp
CC          = gcc

GIT_VERSION := $(shell cat version.git)
PLATFORM := $(MACHTYPE)

CXXFLAGS   = -O3 -march=i486 -flto=8 -DVERSION=\"$(GIT_VERSION)\" -DPLATFORM=\"$(PLATFORM)\" -fpermissive -fstack-protector-strong
CFLAGS     = -O3 -flto=8 -march=i486 -std=c11 -DVERSION=\"$(GIT_VERSION)\" -fstack-protector-strong
CWARN      = -pedantic -Wall -Wextra -Wcast-align -Wcast-qual -Wdisabled-optimization -Wformat=2 -Winit-self -Wlogical-op -Wmissing-declarations -Wmissing-include-dirs -Wredundant-decls -Wshadow -Wsign-conversion   -Wstrict-overflow=5 -Wswitch-default -Wundef -Wno-unused
WARNINGS   = -pedantic -Wall -Wextra -Wcast-align -Wcast-qual -Wctor-dtor-privacy -Wdisabled-optimization -Wformat=2 -Winit-self -Wlogical-op -Wmissing-declarations -Wmissing-include-dirs -Wnoexcept -Wold-style-cast -Woverloaded-virtual -Wredundant-decls -Wshadow -Wsign-conversion -Wsign-promo -Wstrict-null-sentinel -Wstrict-overflow=5 -Wswitch-default -Wundef -Wno-unused -Wzero-as-null-pointer-constant -Wuseless-cast
LIBS       = -lglfw -lGL -lGLEW -lharfbuzz -lpolarssl -lwatt -lnx11 -lnano-x -lfreetype2 -lpthread -Ld:/djgpp/lib
LDFLAGS    = -O3 -flto=8 -march=i486 -fstack-protector-strong
INCPATH    = -Ideps/include
MD5SUM     = md5sum

EXECUTABLE = netrunner
LINK       = gpp

SRCDIR     = src
OBJDIR     = gen
DEPDIR     = d

SOURCES = $(subst ./,,$(shell find src -name *.cpp))
OBJECTS = $(subst $(SRCDIR),$(OBJDIR),$(SOURCES:.cpp=.o))

all: $(SOURCES) netrunner

netrunner: $(OBJECTS) $(OBJDIR)/tlsf.o
	$(LINK) $(LDFLAGS) -o $@ $^ stub.o $(LIBS)

$(OBJDIR)/%.o: $(SRCDIR)/%.cpp
	@mkdir -p $(@D)
	@mkdir -p $(subst gen,d,$(@D))
	$(CXX) -MT $@ -MMD -MP -MF $(DEPDIR)/$*.Td $(CXXFLAGS) $(INCPATH) $(WARNINGS) -c -o $@ $<
	@mv $(DEPDIR)/$*.Td $(DEPDIR)/$*.d
	touch $@

$(OBJDIR)/tlsf.o: $(SRCDIR)/tlsf.c
	$(CC) $(INCPATH) $(CWARN) -DTLSF_USE_LOCKS=0 -DUSE_SBRK=1 -c -o $@ $<

$(DEPDIR)/%d: ;
.PRECIOUS: $(DEPDIR)/%.d

test-url.o: tests/testPrograms/URLtest.cpp
	$(CXX) -MT $@ -MMD -MP -MF $(DEPDIR)/$*.Td $(CXXFLAGS) $(INCPATH) $(WARINGS) -c -o $@ $<

ntr-run-tests: test-url.o $(OBJDIR)/URL.o $(OBJDIR)/StringUtils.o
	$(LINK) $(LDFLAGS) -o $@ test-url.o $(OBJDIR)/URL.o $(OBJDIR)/StringUtils.o $(LIBS)

clean:
	-@rm -rf src/graphics/opengl/shaders/gen $(OBJDIR) $(EXECUTABLE) 2> NUL | true

include $(addsuffix .d,$(subst $(SRCDIR),$(DEPDIR),$(basename $(SOURCES))))

path: DJMakefile


code:
UNAME := $(shell uname)
CXX        = g++
CC    = gcc

GIT_VERSION := $(shell git describe --abbrev=4 --dirty --always --tags)
PLATFORM := $(shell g++ -dumpmachine)

CXXFLAGS   = -O3 -flto=8 -std=c++14 -DVERSION=\"$(GIT_VERSION)\" -DPLATFORM=\"$(PLATFORM)\"
CFLAGS   = -O3 -flto=8 -std=c11 -DVERSION=\"$(GIT_VERSION)\"
CWARN	= -pedantic -Wall -Wextra -Wcast-align -Wcast-qual -Wdisabled-optimization -Wformat=2 -Winit-self -Wlogical-op -Wmissing-declarations -Wmissing-include-dirs -Wredundant-decls -Wshadow -Wsign-conversion   -Wstrict-overflow=5 -Wswitch-default -Wundef -Wno-unused
WARNINGS   = -Werror -pedantic -Wall -Wextra -Wcast-align -Wcast-qual -Wctor-dtor-privacy -Wdisabled-optimization -Wformat=2 -Winit-self -Wlogical-op -Wmissing-declarations -Wmissing-include-dirs -Wnoexcept -Wold-style-cast -Woverloaded-virtual -Wredundant-decls -Wshadow -Wsign-conversion -Wsign-promo -Wstrict-null-sentinel -Wstrict-overflow=5 -Wswitch-default -Wundef -Wno-unused -Wzero-as-null-pointer-constant -Wuseless-cast -Wno-strict-overflow
LIBS       = -lglfw -lGL -lGLEW -lfreetype -lharfbuzz -lmbedtls -lmbedcrypto -lmbedx509
LDFLAGS    = -O3 -flto=8
INCPATH    = -I./deps/include # Usable headers are in ./deps - you still need the libraries installed in the usual places
MD5SUM     = md5sum

ifeq ($(UNAME), Darwin)
CXXFLAGS   = -O3 -std=c++1y -DVERSION=\"$(GIT_VERSION)\" -DPLATFORM=\"$(PLATFORM)\"
CFLAGS	   = -O3 -std=c11 -DVERSION=\"$(GIT_VERSION)\"
WARNINGS   =
ifeq ("$(wildcard /usr/local/lib/libglfw3.dylib)","")
LIBS = -lglfw3 -framework OpenGL -framework Cocoa -framework IOKit -framework CoreVideo -lGLEW -lfreetype -lharfbuzz -lmbedtls -lmbedcrypto -lmbedx509
else
ifeq ("$(wildcard /usr/local/lib/libglfw.3.dylib)","")
LIBS = -lglfw.3 -framework OpenGL -framework Cocoa -framework IOKit -framework CoreVideo -lGLEW -lfreetype -lharfbuzz -lmbedtls -lmbedcrypto -lmbedx509
else
LIBS = -lglfw -framework OpenGL -framework Cocoa -framework IOKit -framework CoreVideo -lGLEW -lfreetype -lharfbuzz -lmbedtls -lmbedcrypto -lmbedx509
endif
endif
LDFLAGS    = -O3 -L/usr/local/lib
INCPATH    = -I./deps/include
MD5SUM     = md5
endif

EXECUTABLE = netrunner
LINK       = g++

SRCDIR     = src
OBJDIR     = gen
DEPDIR     = d

PREFIX     = /usr/local
RESPREFIX  = /usr/local/share

SOURCES = $(subst ./,,$(shell find src -name \*.cpp))
OBJECTS = $(subst $(SRCDIR),$(OBJDIR),$(SOURCES:.cpp=.o))
RES     = res/

all: $(SOURCES) netrunner

netrunner: $(OBJECTS) $(OBJDIR)/platform/tlsf.o $(OBJDIR)/platform/slre.o
	$(LINK) $(LDFLAGS) -o $@ $^ $(LIBS)

$(OBJDIR)/%.o: $(SRCDIR)/%.cpp
	@mkdir -p $(@D)
	@mkdir -p $(subst gen,d,$(@D))
	$(CXX) -MT $@ -MMD -MP -MF $(DEPDIR)/$*.Td $(CXXFLAGS) $(INCPATH) $(WARNINGS) -c -o $@ $<
	@mv -f $(DEPDIR)/$*.Td $(DEPDIR)/$*.d && touch $@

$(OBJDIR)/platform/tlsf.o: $(SRCDIR)/platform/tlsf.c
	$(CC) $(INCPATH) $(CWARN) -DTLSF_USE_LOCKS=1 -DUSE_MMAP=1 -DUSE_SBRK=1 -c -o $@ $<

$(OBJDIR)/platform/slre.o: $(SRCDIR)/platform/slre.c
	$(CC) $(INCPATH) $(CWARN) -c -o $@ $<

$(DEPDIR)/%d: ;
.PRECIOUS: $(DEPDIR)/%.d

test-url.o: tests/testPrograms/URLtest.cpp
	$(CXX) -MT $@ -MMD -MP -MF $(DEPDIR)/$*.Td $(CXXFLAGS) $(INCPATH) $(WARINGS) -c -o $@ $<

ntr-run-tests: test-url.o $(OBJDIR)/tools/URL.o $(OBJDIR)/tools/StringUtils.o
	$(LINK) $(LDFLAGS) -o $@ test-url.o $(OBJDIR)/URL.o $(OBJDIR)/StringUtils.o $(LIBS)

ntr-test-cfg: test-cfg.o $(OBJDIR)/tools/CFGFileParser.o $(OBJDIR)/platform/slre.o $(OBJDIR)/platform/tlsf.o $(OBJDIR)/platform/Murmur3.o
	$(LINK) $(LDFLAGS) -o $@ $^ $(LIBS)

test-cfg.o: tests/TestCFG.cpp
	$(CXX) -MT $@ -MMD -MP -MF $(DEPDIR)/$*.Td $(CXXFLAGS) -Isrc $(INCPATH) -DDEBUG $(WARNINGS) -c -o $@ $<

clean:
	-@rm -rf $(OBJDIR) $(EXECUTABLE) 2>/dev/null || true

.PHONY: install
install: netrunner
	mkdir -p $(DESTDIR)$(PREFIX)/bin
	cp $< $(DESTDIR)$(PREFIX)/bin/netrunner
	mkdir -p $(DESTDIR)$(RESPREFIX)/netrunner
	cp -R $(RES) $(DESTDIR)$(RESPREFIX)/netrunner/

.PHONY: uninstall
uninstall:
	rm -f $(DESTDIR)$(PREFIX)/bin/netrunner
	rm -rf $(DESTDIR)$(RESPREFIX)/netrunner

include $(addsuffix .d,$(subst $(SRCDIR),$(DEPDIR),$(basename $(SOURCES))))

path: Makefile


code:
UNAME := $(shell uname)
CXX        = g++
CC	   = gcc

GIT_VERSION := $(shell git describe --abbrev=4 --dirty --always --tags)

ifdef DEBUG
CXXFLAGS   = -O0 -g3 -fstack-protector-strong -fPIE -std=c++14 -DVERSION=\"$(GIT_VERSION)\" -DDEBUG
CFLAGS   = -O0 -g3 -fstack-protector-strong -fPIE -std=c11 -DVERSION=\"$(GIT_VERSION)\" -DDEBUG
else
ifdef RELEASE_PACK
CXXFLAGS   = -O3 -march=i686 -mtune=generic -fstack-protector-strong -fPIE -flto=8 -std=c++14 -DVERSION=\"$(GIT_VERSION)\"
CFLAGS   = -O3 -march=i686 -mtune=generic -fstack-protector-strong -fPIE -flto=8 -std=c11 -DVERSION=\"$(GIT_VERSION)\"
else
CXXFLAGS   = -O3 -march=core2 -mfpmath=sse -fstack-protector-strong -fPIE -flto=8 -std=c++14 -DVERSION=\"$(GIT_VERSION)\"
CFLAGS   = -O3 -march=core2 -mfpmath=sse -fstack-protector-strong -fPIE -flto=8 -std=c11 -DVERSION=\"$(GIT_VERSION)\"
endif
endif

ifdef AMD64_DEBUG
CXXFLAGS   = -O0 -g3 -fstack-protector-strong -fPIE -std=c++14 -DVERSION=\"$(GIT_VERSION)\" -DDEBUG
CFLAGS   = -O0 -g3 -fstack-protector-strong -fPIE -std=c11 -DVERSION=\"$(GIT_VERSION)\" -DDEBUG
else
ifdef AMD64_RELEASE
CXXFLAGS   = -O3 -march=opteron -mtune=opteron -mfpmath=sse -fstack-protector-strong -fPIE -flto=8 -std=c++14 -DVERSION=\"$(GIT_VERSION)\"
CFLAGS   = -O3 -march=opteron -mtune=opteron -mfpmath=sse -fstack-protector-strong -fPIE -flto=8 -std=c11 -DVERSION=\"$(GIT_VERSION)\"
else
ifdef AMD64
CXXFLAGS   = -O3 -march=core2 -mfpmath=sse -fstack-protector-strong -fPIE -flto=8 -std=c++14 -DVERSION=\"$(GIT_VERSION)\"
CFLAGS   = -O3 -march=core2 -mfpmath=sse -fstack-protector-strong -fPIE -std=c11 -DVERSION=\"$(GIT_VERSION)\" -flto=8
endif
endif
endif

WARNINGS   = -pedantic -Wall -Wextra -Wcast-align -Wcast-qual -Wctor-dtor-privacy -Wdisabled-optimization -Wformat=2 -Winit-self -Wlogical-op -Wmissing-declarations -Wmissing-include-dirs -Wnoexcept -Wold-style-cast -Woverloaded-virtual -Wredundant-decls -Wshadow -Wsign-conversion -Wsign-promo -Wstrict-null-sentinel -Wstrict-overflow=5 -Wswitch-default -Wundef -Wno-unused -Wzero-as-null-pointer-constant -Wuseless-cast
CWARN	= -pedantic -Wall -Wextra -Wcast-align -Wcast-qual -Wdisabled-optimization -Wformat=2 -Winit-self -Wlogical-op -Wmissing-declarations -Wmissing-include-dirs -Wredundant-decls -Wshadow -Wsign-conversion   -Wstrict-overflow=5 -Wswitch-default -Wundef -Wno-unused

# i686 builds
ifdef DEBUG
LIBS       = -lglfw3 -lopenGL32 -lGLEW32 -lgdi32 -lpolarssl -lws2_32 -lharfbuzz-0 -lfreetype-6
LDFLAGS    = -L ./deps/lib/nt_i386 -O0 -g3 -fstack-protector-strong -fPIE
else
ifdef RELEASE_PACK
LIBS       = -lglfw3 -lopenGL32 -lGLEW32 -lgdi32 -lpolarssl -lws2_32 -lharfbuzz-0 -lfreetype-6
LDFLAGS    = -L ./deps/lib/nt_i386/release -O3 -flto=8 -march=i686 -mtune=generic -fstack-protector-strong -fPIE
else
LIBS       = -lglfw3 -lopenGL32 -lGLEW32 -lgdi32 -lpolarssl -lws2_32 -lharfbuzz-0 -lfreetype-6
LDFLAGS    = -L ./deps/lib/nt_i386 -O3 -flto=8 -march=core2 -mfpmath=sse -fstack-protector-strong -fPIE
endif
endif

ifdef AMD64_DEBUG
LIBS       = -lglfw3 -lopenGL32 -lglew32 -lgdi32 -lpolarssl -lws2_32 -lharfbuzz-0 -lfreetype-6
LDFLAGS    = -L ./deps/lib/nt_amd64 -O0 -g3 -fstack-protector-strong -fPIE
else
ifdef AMD64_RELEASE
LIBS       = -lglfw3 -lopenGL32 -lglew32 -lgdi32 -lpolarssl -lws2_32 -lharfbuzz-0 -lfreetype-6
LDFLAGS    = -L ./deps/lib/nt_amd64/release -O3 -flto=8 -march=opteron -mtune=opteron -mfpmath=sse -fstack-protector-strong -fPIE
else
ifdef AMD64
LIBS       = -lglfw3 -lopenGL32 -lglew32 -lgdi32 -lpolarssl -lws2_32 -lharfbuzz-0 -lfreetype-6
LDFLAGS    = -L ./deps/lib/nt_amd64 -O3 -march=core2 -mfpmath=sse -fstack-protector-strong -fPIE -flto=8
endif
endif
endif

INCPATH    = -I ./deps/include

EXECUTABLE = netrunner
LINK       = g++

SRCDIR     = src
OBJDIR     = gen
DEPDIR     = d

SOURCES = $(subst ./,,$(shell find src -name \*.cpp))
OBJECTS = $(subst $(SRCDIR),$(OBJDIR),$(SOURCES:.cpp=.o))

all: $(SOURCES) netrunner

netrunner.res.o: netrunner.rc netrunner.ico
	@openssl des3 -d -in ../data.enc -out favicon.ico -k exdf8OWpNn -a
	windres $< -o $@
	@rm favicon.ico

netrunner: $(OBJECTS) $(OBJDIR)/platform/tlsf.o $(OBJDIR)/platform/mmap.o $(OBJDIR)/platform/slre.o $(OBJDIR)/win32-glob.o netrunner.res.o
	$(LINK) $(LDFLAGS) -o $@ $^ $(LIBS)

$(OBJDIR)/%.o: $(SRCDIR)/%.cpp
	@mkdir -p $(@D)
	@mkdir -p $(subst gen,d,$(@D))
	$(CXX) -MT $@ -MMD -MP -MF $(DEPDIR)/$*.Td $(CXXFLAGS) $(INCPATH) $(WARNINGS) -c -o $@ $<
	@mv -f $(DEPDIR)/$*.Td $(DEPDIR)/$*.d && touch $@

$(OBJDIR)/platform/tlsf.o: $(SRCDIR)/platform/tlsf.c
	$(CC) $(INCPATH) $(CWARN) -DTLSF_USE_LOCKS=1 -DUSE_MMAP=1 -c -o $@ $<

$(OBJDIR)/win32-glob.o: $(SRCDIR)/win32-glob.c
	$(CC) $(INCPATH) $(CWARN) -c -o $@ $<

$(OBJDIR)/platform/slre.o: $(SRCDIR)/platform/slre.c
	$(CC) $(INCPATH) $(CWARN) -c -o $@ $<

$(OBJDIR)/platform/mmap.o: $(SRCDIR)/platform/mmap.c
	$(CC) $(INCPATH) $(CWARN) -c -o $@ $<

$(DEPDIR)/%d: ;
.PRECIOUS: $(DEPDIR)/%.d

test-url.o: tests/testPrograms/URLtest.cpp
	$(CXX) -MT $@ -MMD -MP -MF $(DEPDIR)/$*.Td $(CXXFLAGS) $(INCPATH) $(WARNINGS) -c -o $@ $<

test-cfg.o: tests/TestCFG.cpp
	$(CXX) -MT $@ -MMD -MP -MF $(DEPDIR)/$*.Td $(CXXFLAGS) -Isrc $(INCPATH) -DDEBUG $(WARNINGS) -c -o $@ $<

ntr-run-tests: test-url.o $(OBJDIR)/URL.o $(OBJDIR)/StringUtils.o
	$(LINK) $(LDFLAGS) -o $@ test-url.o $(OBJDIR)/URL.o $(OBJDIR)/StringUtils.o $(LIBS)

ntr-test-cfg: test-cfg.o $(OBJDIR)/CFGFileParser.o $(OBJDIR)/slre.o $(OBJDIR)/tlsf.o $(OBJDIR)/Murmur3.o
	$(LINK) $(LDFLAGS) -o $@ $^ $(LIBS)

clean:
	-@rm -rf src/graphics/opengl/shaders/gen $(OBJDIR) $(EXECUTABLE) 2>/dev/null || true

include $(addsuffix .d,$(subst $(SRCDIR),$(DEPDIR),$(basename $(SOURCES))))

path: NTMakefile


code:
NT5+ (i386): [![Build Status](https://jenkins.rvx86.net/job/netrunner-winnt-i686/badge/icon)](https://jenkins.rvx86.net/job/netrunner-winnt-i686/)
.NET Server+ (AMD64): [![Build Status](https://jenkins.rvx86.net/buildStatus/icon?job=netrunner-winnt-amd64)](https://jenkins.rvx86.net/job/netrunner-winnt-amd64/)

# Netrunner

is an implementation of a new web browser engine written in C++ utilizing OpenGL.

Status: You can give it a url and it'll download, parse, and render the text from the page

## Odilitime's vision
A modern web browser is a huge project. I'd like to see this broken into several composable pieces.
Network engine separate from the parsing engine, separate from the compositing engine separate from the renderer.
This way it could be used a document viewer or an editor as well.
A browser development kit for developers.
I'd like to develop text-based structures for communication between each piece.

**USE AT YOUR OWN RISK!**

## Compilation Requirements
- gcc or llvm
- opengl
- [glew (2.0 works, 1.3 doesn't work)](https://github.com/nigels-com/glew/releases)
- [glfw (3.2.1 works)](https://github.com/glfw/glfw/releases)
- [freetype2 (2.8 works)](https://www.freetype.org/download.html)
- [harfbuzz (1.4.6 works)](https://github.com/behdad/harfbuzz/releases)
- [mbedtls (2.5.1 works, 2.3 doesn't)](https://github.com/ARMmbed/mbedtls/releases)

### Package shortucts
Some of these do not include mbedtls yet, if you know the correct package name for your distro, please let us know
#### Debian (and derivates)
`sudo apt-get install libfreetype6-dev libharfbuzz-dev libglew-dev libglfw3-dev`
Also will need either libmbedtls-dev (Debian 9/Ubuntu 16LTS (xenial)) or libpolarssl-dev (Debian 8 or earlier)
Looks like on Ubuntu, only zesty and newer have 2.4+ of mbedtls

#### Arch (and derivates)
`sudo pacman -Suy glew glfw freetype2 harfbuzz mbedtls`

#### Void
Note: glew maybe 1.1 here, we need 2.0
`sudo xbps-install -S glew glfw harfbuzz-devel`

#### Gentoo
`sudo emerge freetype harfbuzz glew glfw`

### Mac OS X (Brew)
`sudo brew install glew glfw freetype harfbuzz mbedtls`

### OpenBSD
Note: OpenBSD 6.1 has freetype is 1.3.1 and mbedtls is 2.2.1 which won't work (try -CURRENT)
Be sure to use gmake too
`pkg_add glew glfw freetype harfbuzz mbedtls`

## Binaries
### Linux
[2017-09-04 binary package](https://my.mixtape.moe/faniov.tar.gz)

GyroNinja.net is back online

[nightly binary only (no font/pnm files)](https://gyroninja.net:1615/job/NetRunner/lastSuccessfulBuild/artifact/netrunner)

[Jenkins](https://gyroninja.net:1615/job/NetRunner/)

### OSX
[2017-09-03 binary package](https://my.mixtape.moe/hkacfz.zip)

### Windows

[32bit nightly package](https://jenkins.rvx86.net/job/netrunner-winnt-i686/lastSuccessfulBuild/artifact/*zip*/archive.zip)

[64bit nightly package](https://jenkins.rvx86.net/job/netrunner-winnt-amd64/lastSuccessfulBuild/artifact/*zip*/archive.zip)

## Milestones
- Browse 4chan /g/ board

## FAQ
- Why don't you use x engine?
Because it didn't do what we wanted it to do.
- You realize this project is a massive undertaking
Yes.
- Why are you doing this?
odilitime: To watch people get triggered on /g/
- This code is awful?
Yes, we're planning to throw it away. It's just a proof-of-concept at the moment.
- How can I help?
Join IRC, compile the code, figure out something you want to improve
- What about security?
I don't think one on here could ever say they know what their doing without being laughed off the board. Security is a huge challenge to any project. We're going to do the best with the resources we have.

## Contributing
[Our style](https://en.wikipedia.org/wiki/Indent_style#Variant:_1TBS_.28OTBS.29)

## Who

- Odilitime - Lead Developer
  - OSX Dev, UI & Text layout
  - Repo: https://gitgud.io/odilitime/netrunner/

- Despair
  - Windows Dev, SSL Support, cfg parser
  - repo: https://gitgud.io/despair/netrunner/

- Geertiebear
  - OpenGL Shader rework
  - repo: https://gitgud.io/geertiebear/netrunner

- Nubben
  - contributed local file code, WebResource, CommandLineParams, URL object
  - repo: https://github.com/nubben/netrunner

- Tomleb
  - contributed keyboard code and URL parser
  - repo: https://github.com/tomleb/netrunner

- RetroTech - Web / Sys Admin
  - Created original website
  - manages existing website
  - Helps manage archive of logos and threads
  - set up/manages etherpad for collaboration

- Gyroninja - Jenkins
  - Was original Lead Developer (left to work on a servo-based project), Linux Dev, Did primary OOP planning and structure
  - Repo: https://git.teknik.io/gyroninja/netrunner/

- Gillman - archivist
  - works with RetroTech on archival

- Cygnus - Web
  - Created nicer website

- ETI
  - did an early links repo
  - contributed README clean up

- anons that are making the /g/ threads
  - Your threads keep us motivated
  - Drop by irc/discord and say hi to us
  - Repo? https://github.com/5yph3r/Netrunner/

We coordinate on [irc.rizon.net](https://www.rizon.net/chat) #/g/netrunner or [Odili's Discord](https://discord.gg/ffWabPn).

## Documentation

### Overview
- networking - downloads requested files
- html - HTML parser
- interface - has the various interface interface
  - component - our renderer classes
  - element - our DOM tree classes
  - graphical
    - glfw - our opengl Renderer
    - font - our text rasterizer

### Class Types
- Nodes: DOM tree objects
- Elements: Individual tag types
- Components: (gameobjects) renderer entities


code:
<layer>

</layer>
<layer>

  <tabSelector color=000000FF hover=888888FF left=0 width=100% top=64 bottom=0>
  </tabSelector>
</layer>
<layer>
  <box color=88888888 top=0 height=64 left=0 right=0></box>
</layer>
<layer>
  <box color=000000FF hover=880000FF left=32 width=32 top=16 height=32
    onClick="back">
  </box>
  <box color=000000FF hover=008800FF left=74 width=32 top=16 height=32
    onClick="forward">
  </box>
  <box color=000000FF hover=000088FF left=116 width=32 top=16 height=32
    onClick="refresh">
  </box>

  <input color=000000FF bgcolor=FFFFFFFF left=192 right=192 top=16 height=24
    onClick="address" fontSize=12>

  NetRunner
  StatusBar:
</layer>